@mobilenext/mobile-mcp 0.0.10 → 0.0.12

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 CHANGED
@@ -3,6 +3,9 @@
3
3
  This is a [Model Context Protocol (MCP) server](https://github.com/modelcontextprotocol) that enables scalable mobile automation through a platform-agnostic interface, eliminating the need for distinct iOS or Android knowledge.
4
4
  This server allows Agents and LLMs to interact with native iOS/Android applications and devices through structured accessibility snapshots or coordinate-based taps based on screenshots.
5
5
 
6
+ https://github.com/user-attachments/assets/c4e89c4f-cc71-4424-8184-bdbc8c638fa1
7
+
8
+
6
9
  <p align="center">
7
10
  <a href="https://www.npmjs.com/package/@mobilenext/mobile-mcp">
8
11
  <img src="https://img.shields.io/badge/npm-@mobilenext/mobile--mcp-red" alt="npm">
@@ -18,6 +21,7 @@ This server allows Agents and LLMs to interact with native iOS/Android applicati
18
21
  </a>
19
22
  </p>
20
23
 
24
+
21
25
  ### 🚀 Mobile MCP Roadmap: Building the Future of Mobile
22
26
 
23
27
  Join us on our journey as we continuously enhance Mobile MCP!
@@ -46,14 +50,16 @@ How we help to scale mobile automation:
46
50
  ## Mobile MCP Architecture
47
51
 
48
52
  <p align="center">
49
- <a href="https://raw.githubusercontent.com/mobile-next/mobile-next-assets/refs/heads/main/mobile-mcp-arch.png">
50
- <img alt="mobile-mcp" src="https://raw.githubusercontent.com/mobile-next/mobile-next-assets/refs/heads/main/mobile-mcp-arch.png" width="600">
53
+ <a href="https://raw.githubusercontent.com/mobile-next/mobile-next-assets/refs/heads/main/mobile-mcp-arch-1.png">
54
+ <img alt="mobile-mcp" src="https://raw.githubusercontent.com/mobile-next/mobile-next-assets/refs/heads/main/mobile-mcp-arch-1.png" width="600">
51
55
  </a>
52
56
  </p>
53
57
 
54
58
 
55
59
 
56
- ## How to install
60
+ ## Installation and configuration
61
+
62
+ [Detailed guide for Claude Desktop](https://modelcontextprotocol.io/quickstart/user)
57
63
 
58
64
  ```json
59
65
  {
@@ -67,11 +73,14 @@ How we help to scale mobile automation:
67
73
 
68
74
  ```
69
75
 
70
- [From Claude Code:](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview)
76
+ [Claude Code:](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview)
77
+
71
78
  ```
72
79
  claude mcp add mobile -- npx -y @mobilenext/mobile-mcp@latest ⁠
73
80
  ```
74
81
 
82
+ [Read more in our wiki](https://github.com/mobile-next/mobile-mcp/wiki)! 🚀
83
+
75
84
  ## Prerequisites
76
85
 
77
86
  What you will need to connect MCP with your agent and mobile devices:
@@ -102,15 +111,14 @@ On iOS, you'll need Xcode and to run the Simulator before using Mobile MCP with
102
111
  - `xcrun simctl list`
103
112
  - `xcrun simctl boot "iPhone 16"`
104
113
 
105
-
106
114
  # Mobile Commands and interaction tools
107
115
 
108
116
  The commands and tools support both accessibility-based locators (preferred) and coordinate-based inputs, giving you flexibility when accessibility/automation IDs are missing for reliable and seemless automation.
109
117
 
110
- ## mobile_install_app
111
- - **Description:** Installs an app onto the device/emulator
118
+ ## mobile_list_apps
119
+ - **Description:** List all the installed apps on the device
112
120
  - **Parameters:**
113
- - `appPath` (string): Path or URL to the app file (e.g., .apk for Android, .ipa/.app for iOS)
121
+ - `bundleId` (string): The application's unique bundle/package identifier like: com.google.android.keep or com.apple.mobilenotes )
114
122
 
115
123
  ## mobile_launch_app
116
124
  - **Description:** Launches the specified app on the device/emulator
@@ -120,7 +128,21 @@ The commands and tools support both accessibility-based locators (preferred) and
120
128
  ## mobile_terminate_app
121
129
  - **Description:** Terminates a running application
122
130
  - **Parameters:**
123
- - `bundleId` (string): The application's bundle/package identifier
131
+ - `packageName` (string): Based on the application's bundle/package identifier calls am force stop or kills the app based on pid.
132
+
133
+ ## mobile_get_screen_size
134
+ - **Description:** Get the screen size of the mobile device in pixels
135
+ - **Parameters:** None
136
+
137
+ ## mobile_click_on_screen_at_coordinates
138
+ - **Description:** Taps on specified screen coordinates based on coordinates.
139
+ - **Parameters:**
140
+ - `x` (number): X-coordinate
141
+ - `y` (number): Y-coordinate
142
+
143
+ ## mobile_list_elements_on_screen
144
+ - **Description:** List elements on screen and their coordinates, with display text or accessibility label.
145
+ - **Parameters:** None
124
146
 
125
147
  ## mobile_element_tap
126
148
  - **Description:** Taps on a UI element identified by accessibility locator
@@ -133,12 +155,19 @@ The commands and tools support both accessibility-based locators (preferred) and
133
155
  - **Parameters:**
134
156
  - `x` (number): X-coordinate
135
157
  - `y` (number): Y-coordinate
158
+
159
+ ## mobile_press_button
160
+ - **Description:** Press a button on device (home, back, volume, enter, power button.)
161
+ - **Parameters:** None
162
+
163
+ ## mobile_open_url
164
+ - **Description:** Open a URL in browser on device
165
+ - **Parameters:**
166
+ - `url` (string): The URL to be opened (e.g., "https://example.com").
136
167
 
137
- ## mobile_element_send_keys
138
- - **Description:** Types text into a UI element (e.g., TextField)
168
+ ## mobile_type_text
169
+ - **Description:** Types text into a focused UI element (e.g., TextField, SearchField)
139
170
  - **Parameters:**
140
- - `element` (string): Human-readable element description
141
- - `ref` (string): Accessibility/automation ID of the element
142
171
  - `text` (string): Text to type
143
172
  - `submit` (boolean): Whether to press Enter/Return after typing
144
173
 
@@ -165,20 +194,18 @@ The commands and tools support both accessibility-based locators (preferred) and
165
194
 
166
195
  ## mobile_take_screenshot
167
196
  - **Description:** Captures a screenshot of the current device screen
168
- - **Parameters:**
169
- - `raw` (boolean): Return a lossless image if true; otherwise, compressed by default
197
+ - **Parameters:** None
170
198
 
171
199
  ## mobile_get_source
172
200
  - **Description:** Fetches the current device UI structure (accessibility snapshot) (xml format)
173
201
  - **Parameters:** None
174
202
 
175
- ## mobile_wait
176
- - **Description:** Waits for a specified time
177
- - **Parameters:**
178
- - `time` (number): Time to wait in seconds (capped at 10 seconds)
179
203
 
180
- ## mobile_close_session
181
- - **Description:** Closes the current device session
182
- - **Parameters:** None
204
+ # Thanks to all contributors ❤️
205
+
206
+ ### We appreciate everyone who has helped improve this project.
183
207
 
208
+ <a href = "https://github.com/mobile-next/mobile-mcp/graphs/contributors">
209
+ <img src = "https://contrib.rocks/image?repo=mobile-next/mobile-mcp"/>
210
+ </a>
184
211
 
package/lib/android.js CHANGED
@@ -32,140 +32,168 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.listApps = exports.takeScreenshot = exports.swipe = exports.getElementsOnScreen = exports.getScreenSize = exports.resolveLaunchableActivities = exports.getConnectedDevices = void 0;
39
+ exports.getConnectedDevices = exports.AndroidRobot = void 0;
40
+ const path_1 = __importDefault(require("path"));
37
41
  const child_process_1 = require("child_process");
38
42
  const xml = __importStar(require("fast-xml-parser"));
39
- const fs_1 = require("fs");
40
- const getConnectedDevices = () => {
41
- return (0, child_process_1.execSync)(`adb devices`)
42
- .toString()
43
- .split("\n")
44
- .filter(line => !line.startsWith("List of devices attached"))
45
- .filter(line => line.trim() !== "");
43
+ const robot_1 = require("./robot");
44
+ const getAdbPath = () => {
45
+ let executable = "adb";
46
+ if (process.env.ANDROID_HOME) {
47
+ executable = path_1.default.join(process.env.ANDROID_HOME, "platform-tools", "adb");
48
+ }
49
+ return executable;
46
50
  };
47
- exports.getConnectedDevices = getConnectedDevices;
48
- const resolveLaunchableActivities = (packageName) => {
49
- return (0, child_process_1.execSync)(`adb shell cmd package resolve-activity ${packageName}`)
50
- .toString()
51
- .split("\n")
52
- .map(line => line.trim())
53
- .filter(line => line.startsWith("name="))
54
- .map(line => line.substring("name=".length));
51
+ const BUTTON_MAP = {
52
+ "BACK": "KEYCODE_BACK",
53
+ "HOME": "KEYCODE_HOME",
54
+ "VOLUME_UP": "KEYCODE_VOLUME_UP",
55
+ "VOLUME_DOWN": "KEYCODE_VOLUME_DOWN",
56
+ "ENTER": "KEYCODE_ENTER",
55
57
  };
56
- exports.resolveLaunchableActivities = resolveLaunchableActivities;
57
- const getScreenSize = () => {
58
- const screenSize = (0, child_process_1.execSync)("adb shell wm size")
59
- .toString()
60
- .split(" ")
61
- .pop();
62
- if (!screenSize) {
63
- throw new Error("Failed to get screen size");
58
+ const TIMEOUT = 30000;
59
+ const MAX_BUFFER_SIZE = 1024 * 1024 * 4;
60
+ class AndroidRobot {
61
+ deviceId;
62
+ constructor(deviceId) {
63
+ this.deviceId = deviceId;
64
64
  }
65
- const [width, height] = screenSize.split("x").map(Number);
66
- return [width, height];
67
- };
68
- exports.getScreenSize = getScreenSize;
69
- const collectElements = (node, screenSize) => {
70
- const elements = [];
71
- const getCoordinates = (element) => {
72
- const bounds = String(element.bounds);
73
- const [, left, top, right, bottom] = bounds.match(/^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$/)?.map(Number) || [];
74
- return { left, top, right, bottom };
75
- };
76
- const getCenter = (coordinates) => {
77
- return {
78
- x: Math.floor((coordinates.left + coordinates.right) / 2),
79
- y: Math.floor((coordinates.top + coordinates.bottom) / 2),
80
- };
81
- };
82
- const normalizeCoordinates = (coordinates, screenSize) => {
83
- return {
84
- x: Number((coordinates.x / screenSize[0]).toFixed(3)),
85
- y: Number((coordinates.y / screenSize[1]).toFixed(3)),
65
+ adb(...args) {
66
+ return (0, child_process_1.execFileSync)(getAdbPath(), ["-s", this.deviceId, ...args], {
67
+ maxBuffer: MAX_BUFFER_SIZE,
68
+ timeout: TIMEOUT,
69
+ });
70
+ }
71
+ async getScreenSize() {
72
+ const screenSize = this.adb("shell", "wm", "size")
73
+ .toString()
74
+ .split(" ")
75
+ .pop();
76
+ if (!screenSize) {
77
+ throw new Error("Failed to get screen size");
78
+ }
79
+ const scale = 1;
80
+ const [width, height] = screenSize.split("x").map(Number);
81
+ return { width, height, scale };
82
+ }
83
+ async listApps() {
84
+ return this.adb("shell", "cmd", "package", "query-activities", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER")
85
+ .toString()
86
+ .split("\n")
87
+ .map(line => line.trim())
88
+ .filter(line => line.startsWith("packageName="))
89
+ .map(line => line.substring("packageName=".length))
90
+ .filter((value, index, self) => self.indexOf(value) === index)
91
+ .map(packageName => ({
92
+ packageName,
93
+ appName: packageName,
94
+ }));
95
+ }
96
+ async launchApp(packageName) {
97
+ this.adb("shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1");
98
+ }
99
+ async swipe(direction) {
100
+ const screenSize = await this.getScreenSize();
101
+ const centerX = screenSize.width >> 1;
102
+ // const centerY = screenSize[1] >> 1;
103
+ let x0, y0, x1, y1;
104
+ switch (direction) {
105
+ case "up":
106
+ x0 = x1 = centerX;
107
+ y0 = Math.floor(screenSize.height * 0.80);
108
+ y1 = Math.floor(screenSize.height * 0.20);
109
+ break;
110
+ case "down":
111
+ x0 = x1 = centerX;
112
+ y0 = Math.floor(screenSize.height * 0.20);
113
+ y1 = Math.floor(screenSize.height * 0.80);
114
+ break;
115
+ default:
116
+ throw new robot_1.ActionableError(`Swipe direction "${direction}" is not supported`);
117
+ }
118
+ this.adb("shell", "input", "swipe", `${x0}`, `${y0}`, `${x1}`, `${y1}`, "1000");
119
+ }
120
+ async getScreenshot() {
121
+ return this.adb("shell", "screencap", "-p");
122
+ }
123
+ collectElements(node) {
124
+ const elements = [];
125
+ const getScreenElementRect = (element) => {
126
+ const bounds = String(element.bounds);
127
+ const [, left, top, right, bottom] = bounds.match(/^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$/)?.map(Number) || [];
128
+ return {
129
+ x: left,
130
+ y: top,
131
+ width: right - left,
132
+ height: bottom - top,
133
+ };
86
134
  };
87
- };
88
- if (node.node) {
89
- if (Array.isArray(node.node)) {
90
- for (const childNode of node.node) {
91
- elements.push(...collectElements(childNode, screenSize));
135
+ if (node.node) {
136
+ if (Array.isArray(node.node)) {
137
+ for (const childNode of node.node) {
138
+ elements.push(...this.collectElements(childNode));
139
+ }
140
+ }
141
+ else {
142
+ elements.push(...this.collectElements(node.node));
92
143
  }
93
144
  }
94
- else {
95
- elements.push(...collectElements(node.node, screenSize));
145
+ if (node.text || node["content-desc"] || node.hint) {
146
+ const element = {
147
+ type: node.class || "text",
148
+ name: node.text,
149
+ label: node["content-desc"] || node.hint || "",
150
+ rect: getScreenElementRect(node),
151
+ };
152
+ if (element.rect.width > 0 && element.rect.height > 0) {
153
+ elements.push(element);
154
+ }
96
155
  }
156
+ return elements;
97
157
  }
98
- if (node.text) {
99
- elements.push({
100
- "text": node.text,
101
- "coordinates": normalizeCoordinates(getCenter(getCoordinates(node)), screenSize),
158
+ async getElementsOnScreen() {
159
+ const dump = this.adb("exec-out", "uiautomator", "dump", "/dev/tty");
160
+ const parser = new xml.XMLParser({
161
+ ignoreAttributes: false,
162
+ attributeNamePrefix: ""
102
163
  });
164
+ const parsedXml = parser.parse(dump);
165
+ const hierarchy = parsedXml.hierarchy;
166
+ const elements = this.collectElements(hierarchy.node);
167
+ return elements;
103
168
  }
104
- if (node["content-desc"]) {
105
- elements.push({
106
- "text": node["content-desc"],
107
- "coordinates": normalizeCoordinates(getCenter(getCoordinates(node)), screenSize),
108
- });
169
+ async terminateApp(packageName) {
170
+ this.adb("shell", "am", "force-stop", packageName);
109
171
  }
110
- return elements;
111
- };
112
- const getElementsOnScreen = () => {
113
- const dump = (0, child_process_1.execSync)(`adb exec-out uiautomator dump /dev/tty`);
114
- const parser = new xml.XMLParser({
115
- ignoreAttributes: false,
116
- attributeNamePrefix: ""
117
- });
118
- const parsedXml = parser.parse(dump);
119
- const hierarchy = parsedXml.hierarchy;
120
- const screenSize = (0, exports.getScreenSize)();
121
- const elements = collectElements(hierarchy, screenSize);
122
- return elements;
123
- };
124
- exports.getElementsOnScreen = getElementsOnScreen;
125
- const swipe = (direction) => {
126
- const screenSize = (0, exports.getScreenSize)();
127
- const centerX = screenSize[0] >> 1;
128
- // const centerY = screenSize[1] >> 1;
129
- let x0, y0, x1, y1;
130
- switch (direction) {
131
- case "up":
132
- x0 = x1 = centerX;
133
- y0 = Math.floor(screenSize[1] * 0.80);
134
- y1 = Math.floor(screenSize[1] * 0.20);
135
- break;
136
- case "down":
137
- x0 = x1 = centerX;
138
- y0 = Math.floor(screenSize[1] * 0.20);
139
- y1 = Math.floor(screenSize[1] * 0.80);
140
- break;
141
- default:
142
- throw new Error(`Swipe direction "${direction}" is not supported`);
143
- }
144
- (0, child_process_1.execSync)(`adb shell input swipe ${x0} ${y0} ${x1} ${y1} 1000`);
145
- };
146
- exports.swipe = swipe;
147
- const takeScreenshot = async () => {
148
- const randomFilename = `screenshot-${Date.now()}.png`;
149
- // take screenshot and save on device
150
- const remoteFilename = `/sdcard/Download/${randomFilename}`;
151
- (0, child_process_1.execSync)(`adb shell screencap -p ${remoteFilename}`);
152
- // pull the file locally
153
- const localFilename = `/tmp/${randomFilename}`;
154
- (0, child_process_1.execSync)(`adb pull ${remoteFilename} ${localFilename}`);
155
- (0, child_process_1.execSync)(`adb shell rm ${remoteFilename}`);
156
- const screenshot = (0, fs_1.readFileSync)(localFilename);
157
- (0, fs_1.unlinkSync)(localFilename);
158
- return screenshot;
159
- };
160
- exports.takeScreenshot = takeScreenshot;
161
- const listApps = () => {
162
- const result = (0, child_process_1.execSync)(`adb shell cmd package query-activities -a android.intent.action.MAIN -c android.intent.category.LAUNCHER`)
172
+ async openUrl(url) {
173
+ this.adb("shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", url);
174
+ }
175
+ async sendKeys(text) {
176
+ // adb shell requires some escaping
177
+ const _text = text.replace(/ /g, "\\ ");
178
+ this.adb("shell", "input", "text", _text);
179
+ }
180
+ async pressButton(button) {
181
+ if (!BUTTON_MAP[button]) {
182
+ throw new robot_1.ActionableError(`Button "${button}" is not supported`);
183
+ }
184
+ this.adb("shell", "input", "keyevent", BUTTON_MAP[button]);
185
+ }
186
+ async tap(x, y) {
187
+ this.adb("shell", "input", "tap", `${x}`, `${y}`);
188
+ }
189
+ }
190
+ exports.AndroidRobot = AndroidRobot;
191
+ const getConnectedDevices = () => {
192
+ return (0, child_process_1.execFileSync)(getAdbPath(), ["devices"])
163
193
  .toString()
164
194
  .split("\n")
165
- .map(line => line.trim())
166
- .filter(line => line.startsWith("packageName="))
167
- .map(line => line.substring("packageName=".length))
168
- .filter((value, index, self) => self.indexOf(value) === index);
169
- return result;
195
+ .filter(line => !line.startsWith("List of devices attached"))
196
+ .filter(line => line.trim() !== "")
197
+ .map(line => line.split("\t")[0]);
170
198
  };
171
- exports.listApps = listApps;
199
+ exports.getConnectedDevices = getConnectedDevices;
package/lib/ios.js ADDED
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.IosManager = exports.IosRobot = void 0;
7
+ const path_1 = __importDefault(require("path"));
8
+ const os_1 = require("os");
9
+ const crypto_1 = require("crypto");
10
+ const fs_1 = require("fs");
11
+ const child_process_1 = require("child_process");
12
+ const net_1 = require("net");
13
+ const webdriver_agent_1 = require("./webdriver-agent");
14
+ const robot_1 = require("./robot");
15
+ const WDA_PORT = 8100;
16
+ const IOS_TUNNEL_PORT = 60105;
17
+ const getGoIosPath = () => {
18
+ if (process.env.GO_IOS_PATH) {
19
+ return process.env.GO_IOS_PATH;
20
+ }
21
+ // fallback to go-ios in PATH via `npm install -g go-ios`
22
+ return "ios";
23
+ };
24
+ class IosRobot {
25
+ deviceId;
26
+ constructor(deviceId) {
27
+ this.deviceId = deviceId;
28
+ }
29
+ isListeningOnPort(port) {
30
+ return new Promise((resolve, reject) => {
31
+ const client = new net_1.Socket();
32
+ client.connect(port, "localhost", () => {
33
+ client.destroy();
34
+ resolve(true);
35
+ });
36
+ client.on("error", (err) => {
37
+ resolve(false);
38
+ });
39
+ });
40
+ }
41
+ async isTunnelRunning() {
42
+ return await this.isListeningOnPort(IOS_TUNNEL_PORT);
43
+ }
44
+ async isWdaForwardRunning() {
45
+ return await this.isListeningOnPort(WDA_PORT);
46
+ }
47
+ async assertTunnelRunning() {
48
+ if (await this.isTunnelRequired()) {
49
+ if (!(await this.isTunnelRunning())) {
50
+ throw new robot_1.ActionableError("iOS tunnel is not running, please see https://github.com/mobile-next/mobile-mcp/wiki/");
51
+ }
52
+ }
53
+ }
54
+ async wda() {
55
+ await this.assertTunnelRunning();
56
+ if (!(await this.isWdaForwardRunning())) {
57
+ throw new robot_1.ActionableError("Port forwarding to WebDriverAgent is not running (tunnel okay), please see https://github.com/mobile-next/mobile-mcp/wiki/");
58
+ }
59
+ const wda = new webdriver_agent_1.WebDriverAgent("localhost", WDA_PORT);
60
+ if (!(await wda.isRunning())) {
61
+ throw new robot_1.ActionableError("WebDriverAgent is not running on device (tunnel okay, port forwarding okay), please see https://github.com/mobile-next/mobile-mcp/wiki/");
62
+ }
63
+ return wda;
64
+ }
65
+ async ios(...args) {
66
+ return (0, child_process_1.execFileSync)(getGoIosPath(), ["--udid", this.deviceId, ...args], {}).toString();
67
+ }
68
+ async getIosVersion() {
69
+ const output = await this.ios("info");
70
+ const json = JSON.parse(output);
71
+ return json.ProductVersion;
72
+ }
73
+ async isTunnelRequired() {
74
+ const version = await this.getIosVersion();
75
+ const args = version.split(".");
76
+ return parseInt(args[0], 10) >= 17;
77
+ }
78
+ async getScreenSize() {
79
+ const wda = await this.wda();
80
+ return await wda.getScreenSize();
81
+ }
82
+ async swipe(direction) {
83
+ const wda = await this.wda();
84
+ await wda.swipe(direction);
85
+ }
86
+ async listApps() {
87
+ await this.assertTunnelRunning();
88
+ const output = await this.ios("apps", "--all", "--list");
89
+ return output
90
+ .split("\n")
91
+ .map(line => {
92
+ const [packageName, appName] = line.split(" ");
93
+ return {
94
+ packageName,
95
+ appName,
96
+ };
97
+ });
98
+ }
99
+ async launchApp(packageName) {
100
+ await this.assertTunnelRunning();
101
+ await this.ios("launch", packageName);
102
+ }
103
+ async terminateApp(packageName) {
104
+ await this.assertTunnelRunning();
105
+ await this.ios("kill", packageName);
106
+ }
107
+ async openUrl(url) {
108
+ const wda = await this.wda();
109
+ await wda.openUrl(url);
110
+ }
111
+ async sendKeys(text) {
112
+ const wda = await this.wda();
113
+ await wda.sendKeys(text);
114
+ }
115
+ async pressButton(button) {
116
+ const wda = await this.wda();
117
+ await wda.pressButton(button);
118
+ }
119
+ async tap(x, y) {
120
+ const wda = await this.wda();
121
+ await wda.tap(x, y);
122
+ }
123
+ async getElementsOnScreen() {
124
+ const wda = await this.wda();
125
+ return await wda.getElementsOnScreen();
126
+ }
127
+ async getScreenshot() {
128
+ await this.assertTunnelRunning();
129
+ const tmpFilename = path_1.default.join((0, os_1.tmpdir)(), `screenshot-${(0, crypto_1.randomBytes)(8).toString("hex")}.png`);
130
+ await this.ios("screenshot", "--output", tmpFilename);
131
+ const buffer = (0, fs_1.readFileSync)(tmpFilename);
132
+ (0, fs_1.unlinkSync)(tmpFilename);
133
+ return buffer;
134
+ }
135
+ }
136
+ exports.IosRobot = IosRobot;
137
+ class IosManager {
138
+ async isGoIosInstalled() {
139
+ try {
140
+ const output = (0, child_process_1.execFileSync)(getGoIosPath(), ["version"], { stdio: ["pipe", "pipe", "ignore"] }).toString();
141
+ const json = JSON.parse(output);
142
+ return json.version !== undefined && (json.version.startsWith("v") || json.version === "local-build");
143
+ }
144
+ catch (error) {
145
+ return false;
146
+ }
147
+ }
148
+ async listDevices() {
149
+ if (!(await this.isGoIosInstalled())) {
150
+ console.error("go-ios is not installed, no physical iOS devices can be detected");
151
+ return [];
152
+ }
153
+ const output = (0, child_process_1.execFileSync)(getGoIosPath(), ["list"]).toString();
154
+ const json = JSON.parse(output);
155
+ return json.deviceList;
156
+ }
157
+ }
158
+ exports.IosManager = IosManager;
@@ -1,40 +1,127 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.listApps = exports.launchApp = exports.openUrl = exports.getScreenshot = exports.getConnectedDevices = void 0;
3
+ exports.SimctlManager = exports.Simctl = void 0;
4
4
  const child_process_1 = require("child_process");
5
- const getConnectedDevices = () => {
6
- return (0, child_process_1.execSync)(`xcrun simctl list devices`)
7
- .toString()
8
- .split("\n")
9
- .map(line => {
10
- // extract device name and UUID from the line
11
- const match = line.match(/(.*?)\s+\(([\w-]+)\)\s+\(Booted\)/);
12
- if (!match) {
13
- return null;
5
+ const webdriver_agent_1 = require("./webdriver-agent");
6
+ const robot_1 = require("./robot");
7
+ const TIMEOUT = 30000;
8
+ const WDA_PORT = 8100;
9
+ const MAX_BUFFER_SIZE = 1024 * 1024 * 4;
10
+ class Simctl {
11
+ simulatorUuid;
12
+ constructor(simulatorUuid) {
13
+ this.simulatorUuid = simulatorUuid;
14
+ }
15
+ async wda() {
16
+ const wda = new webdriver_agent_1.WebDriverAgent("localhost", WDA_PORT);
17
+ if (!(await wda.isRunning())) {
18
+ throw new robot_1.ActionableError("WebDriverAgent is not running on device (tunnel okay, port forwarding okay), please see https://github.com/mobile-next/mobile-mcp/wiki/");
14
19
  }
15
- const deviceName = match[1].trim();
16
- const deviceUuid = match[2];
17
- return {
18
- name: deviceName,
19
- uuid: deviceUuid,
20
- };
21
- })
22
- .filter(line => line !== null);
23
- };
24
- exports.getConnectedDevices = getConnectedDevices;
25
- const getScreenshot = (simulatorUuid) => {
26
- return (0, child_process_1.execSync)(`xcrun simctl io "${simulatorUuid}" screenshot -`);
27
- };
28
- exports.getScreenshot = getScreenshot;
29
- const openUrl = (simulatorUuid, url) => {
30
- return (0, child_process_1.execSync)(`xcrun simctl openurl "${simulatorUuid}" "${url}"`);
31
- };
32
- exports.openUrl = openUrl;
33
- const launchApp = (simulatorUuid, packageName) => {
34
- return (0, child_process_1.execSync)(`xcrun simctl launch "${simulatorUuid}" "${packageName}"`);
35
- };
36
- exports.launchApp = launchApp;
37
- const listApps = (simulatorUuid) => {
38
- return (0, child_process_1.execSync)(`xcrun simctl list apps "${simulatorUuid}"`);
39
- };
40
- exports.listApps = listApps;
20
+ return wda;
21
+ }
22
+ simctl(...args) {
23
+ return (0, child_process_1.execFileSync)("xcrun", ["simctl", ...args], {
24
+ timeout: TIMEOUT,
25
+ maxBuffer: MAX_BUFFER_SIZE,
26
+ });
27
+ }
28
+ async getScreenshot() {
29
+ return this.simctl("io", this.simulatorUuid, "screenshot", "-");
30
+ }
31
+ async openUrl(url) {
32
+ const wda = await this.wda();
33
+ await wda.openUrl(url);
34
+ // alternative: this.simctl("openurl", this.simulatorUuid, url);
35
+ }
36
+ async launchApp(packageName) {
37
+ this.simctl("launch", this.simulatorUuid, packageName);
38
+ }
39
+ async terminateApp(packageName) {
40
+ this.simctl("terminate", this.simulatorUuid, packageName);
41
+ }
42
+ parseIOSAppData(inputText) {
43
+ const result = [];
44
+ // Remove leading and trailing characters if needed
45
+ const cleanText = inputText.trim();
46
+ // Extract each app section
47
+ const appRegex = /"([^"]+)"\s+=\s+\{([^}]+)\};/g;
48
+ let appMatch;
49
+ while ((appMatch = appRegex.exec(cleanText)) !== null) {
50
+ // const bundleId = appMatch[1];
51
+ const appContent = appMatch[2];
52
+ const appInfo = {};
53
+ // parse simple key-value pairs
54
+ const keyValueRegex = /\s+(\w+)\s+=\s+([^;]+);/g;
55
+ let keyValueMatch;
56
+ while ((keyValueMatch = keyValueRegex.exec(appContent)) !== null) {
57
+ const key = keyValueMatch[1];
58
+ let value = keyValueMatch[2].trim();
59
+ // Handle quoted string values
60
+ if (value.startsWith('"') && value.endsWith('"')) {
61
+ value = value.substring(1, value.length - 1);
62
+ }
63
+ if (key !== "GroupContainers" && key !== "SBAppTags") {
64
+ appInfo[key] = value;
65
+ }
66
+ }
67
+ result.push(appInfo);
68
+ }
69
+ return result;
70
+ }
71
+ async listApps() {
72
+ const text = this.simctl("listapps", this.simulatorUuid).toString();
73
+ const apps = this.parseIOSAppData(text);
74
+ return apps.map(app => ({
75
+ packageName: app.CFBundleIdentifier,
76
+ appName: app.CFBundleDisplayName,
77
+ }));
78
+ }
79
+ async getScreenSize() {
80
+ const wda = await this.wda();
81
+ return wda.getScreenSize();
82
+ }
83
+ async sendKeys(keys) {
84
+ const wda = await this.wda();
85
+ return wda.sendKeys(keys);
86
+ }
87
+ async swipe(direction) {
88
+ const wda = await this.wda();
89
+ return wda.swipe(direction);
90
+ }
91
+ async tap(x, y) {
92
+ const wda = await this.wda();
93
+ return wda.tap(x, y);
94
+ }
95
+ async pressButton(button) {
96
+ const wda = await this.wda();
97
+ return wda.pressButton(button);
98
+ }
99
+ async getElementsOnScreen() {
100
+ const wda = await this.wda();
101
+ return wda.getElementsOnScreen();
102
+ }
103
+ }
104
+ exports.Simctl = Simctl;
105
+ class SimctlManager {
106
+ listSimulators() {
107
+ const text = (0, child_process_1.execFileSync)("xcrun", ["simctl", "list", "devices", "-j"]).toString();
108
+ const json = JSON.parse(text);
109
+ return Object.values(json.devices).flatMap(device => {
110
+ return device.map(d => {
111
+ return {
112
+ name: d.name,
113
+ uuid: d.udid,
114
+ state: d.state,
115
+ };
116
+ });
117
+ });
118
+ }
119
+ listBootedSimulators() {
120
+ return this.listSimulators()
121
+ .filter(simulator => simulator.state === "Booted");
122
+ }
123
+ getSimulator(uuid) {
124
+ return new Simctl(uuid);
125
+ }
126
+ }
127
+ exports.SimctlManager = SimctlManager;
package/lib/robot.js ADDED
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ActionableError = void 0;
4
+ class ActionableError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ }
8
+ }
9
+ exports.ActionableError = ActionableError;
package/lib/server.js CHANGED
@@ -5,11 +5,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createMcpServer = void 0;
7
7
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
8
- const child_process_1 = require("child_process");
9
- const logger_1 = require("./logger");
10
8
  const zod_1 = require("zod");
11
- const android_1 = require("./android");
12
9
  const sharp_1 = __importDefault(require("sharp"));
10
+ const logger_1 = require("./logger");
11
+ const android_1 = require("./android");
12
+ const robot_1 = require("./robot");
13
+ const iphone_simulator_1 = require("./iphone-simulator");
14
+ const ios_1 = require("./ios");
13
15
  const getAgentVersion = () => {
14
16
  const json = require("../package.json");
15
17
  return json.version;
@@ -34,77 +36,136 @@ const createMcpServer = () => {
34
36
  };
35
37
  }
36
38
  catch (error) {
37
- (0, logger_1.trace)(`Tool '${description}' failed: ${error.message} stack: ${error.stack}`);
38
- return {
39
- content: [{ type: "text", text: `Error: ${error.message}` }],
40
- isError: true,
41
- };
39
+ if (error instanceof robot_1.ActionableError) {
40
+ return {
41
+ content: [{ type: "text", text: `${error.message}. Please fix the issue and try again.` }],
42
+ };
43
+ }
44
+ else {
45
+ // a real exception
46
+ (0, logger_1.trace)(`Tool '${description}' failed: ${error.message} stack: ${error.stack}`);
47
+ return {
48
+ content: [{ type: "text", text: `Error: ${error.message}` }],
49
+ isError: true,
50
+ };
51
+ }
42
52
  }
43
53
  };
44
54
  server.tool(name, description, paramsSchema, args => wrappedCb(args));
45
55
  };
46
- tool("list_apps_on_device", "List all apps on device", {}, async ({}) => {
47
- const result = (0, android_1.listApps)();
48
- return `Found these packages on device: ${result.join(",")}`;
56
+ let robot;
57
+ const simulatorManager = new iphone_simulator_1.SimctlManager();
58
+ const requireRobot = () => {
59
+ if (!robot) {
60
+ throw new robot_1.ActionableError("No device selected. Use the mobile_use_device tool to select a device.");
61
+ }
62
+ };
63
+ tool("mobile_list_available_devices", "List all available devices. This includes both physical devices and simulators. If there is more than one device returned, you need to let the user select one of them.", {}, async ({}) => {
64
+ const iosManager = new ios_1.IosManager();
65
+ const devices = await simulatorManager.listBootedSimulators();
66
+ const simulatorNames = devices.map(d => d.name);
67
+ const androidDevices = (0, android_1.getConnectedDevices)();
68
+ const iosDevices = await iosManager.listDevices();
69
+ return `Found these iOS simulators: [${simulatorNames.join(".")}], iOS devices: [${iosDevices.join(",")}] and Android devices: [${androidDevices.join(",")}]`;
70
+ });
71
+ tool("mobile_use_device", "Select a device to use. This can be a simulator or an Android device. Use the list_available_devices tool to get a list of available devices.", {
72
+ device: zod_1.z.string().describe("The name of the device to select"),
73
+ deviceType: zod_1.z.enum(["simulator", "ios", "android"]).describe("The type of device to select"),
74
+ }, async ({ device, deviceType }) => {
75
+ switch (deviceType) {
76
+ case "simulator":
77
+ robot = simulatorManager.getSimulator(device);
78
+ break;
79
+ case "ios":
80
+ robot = new ios_1.IosRobot(device);
81
+ break;
82
+ case "android":
83
+ robot = new android_1.AndroidRobot(device);
84
+ break;
85
+ }
86
+ return `Selected device: ${device} (${deviceType})`;
87
+ });
88
+ tool("mobile_list_apps", "List all the installed apps on the device", {}, async ({}) => {
89
+ requireRobot();
90
+ const result = await robot.listApps();
91
+ return `Found these apps on device: ${result.map(app => `${app.appName} (${app.packageName})`).join(", ")}`;
49
92
  });
50
- tool("launch_app", "Launch an app on mobile device", {
93
+ tool("mobile_launch_app", "Launch an app on mobile device. Use this to open a specific app. You can find the package name of the app by calling list_apps_on_device.", {
51
94
  packageName: zod_1.z.string().describe("The package name of the app to launch"),
52
95
  }, async ({ packageName }) => {
53
- (0, child_process_1.execSync)(`adb shell monkey -p "${packageName}" -c android.intent.category.LAUNCHER 1`);
96
+ requireRobot();
97
+ await robot.launchApp(packageName);
54
98
  return `Launched app ${packageName}`;
55
99
  });
56
- tool("terminate_app", "Stop and terminate an app on mobile device", {
100
+ tool("mobile_terminate_app", "Stop and terminate an app on mobile device", {
57
101
  packageName: zod_1.z.string().describe("The package name of the app to terminate"),
58
102
  }, async ({ packageName }) => {
59
- (0, child_process_1.execSync)(`adb shell am force-stop "${packageName}"`);
103
+ requireRobot();
104
+ await robot.terminateApp(packageName);
60
105
  return `Terminated app ${packageName}`;
61
106
  });
62
- tool("get_screen_size", "Get the screen size of the mobile device in pixels", {}, async ({}) => {
63
- const screenSize = (0, android_1.getScreenSize)();
64
- return `Screen size is ${screenSize[0]}x${screenSize[1]} pixels`;
107
+ tool("mobile_get_screen_size", "Get the screen size of the mobile device in pixels", {}, async ({}) => {
108
+ requireRobot();
109
+ const screenSize = await robot.getScreenSize();
110
+ return `Screen size is ${screenSize.width}x${screenSize.height} pixels`;
65
111
  });
66
- tool("click_on_screen_at_coordinates", "Click on the screen at given x,y coordinates", {
67
- x: zod_1.z.number().describe("The x coordinate to click between 0 and 1"),
68
- y: zod_1.z.number().describe("The y coordinate to click between 0 and 1"),
112
+ tool("mobile_click_on_screen_at_coordinates", "Click on the screen at given x,y coordinates", {
113
+ x: zod_1.z.number().describe("The x coordinate to click on the screen, in pixels"),
114
+ y: zod_1.z.number().describe("The y coordinate to click on the screen, in pixels"),
69
115
  }, async ({ x, y }) => {
70
- const screenSize = (0, android_1.getScreenSize)();
71
- const x0 = Math.floor(screenSize[0] * x);
72
- const y0 = Math.floor(screenSize[1] * y);
73
- (0, child_process_1.execSync)(`adb shell input tap ${x0} ${y0}`);
116
+ requireRobot();
117
+ await robot.tap(x, y);
74
118
  return `Clicked on screen at coordinates: ${x}, ${y}`;
75
119
  });
76
- tool("list_elements_on_screen", "List elements on screen and their coordinates, with display text or accessibility label. Do not cache this result.", {}, async ({}) => {
77
- const elements = (0, android_1.getElementsOnScreen)();
78
- return `Found these elements on screen: ${JSON.stringify(elements)}`;
120
+ tool("mobile_list_elements_on_screen", "List elements on screen and their coordinates, with display text or accessibility label. Do not cache this result.", {}, async ({}) => {
121
+ requireRobot();
122
+ const elements = await robot.getElementsOnScreen();
123
+ const result = elements.map(element => {
124
+ const x = Number((element.rect.x + element.rect.width / 2)).toFixed(3);
125
+ const y = Number((element.rect.y + element.rect.height / 2)).toFixed(3);
126
+ return {
127
+ text: element.label,
128
+ coordinates: { x, y }
129
+ };
130
+ });
131
+ return `Found these elements on screen: ${JSON.stringify(result)}`;
79
132
  });
80
- tool("press_button", "Press a button on device", {
81
- button: zod_1.z.string().describe("The button to press. Supported buttons: KEYCODE_BACK, KEYCODE_HOME, KEYCODE_MENU, KEYCODE_VOLUME_UP, KEYCODE_VOLUME_DOWN, KEYCODE_ENTER"),
133
+ tool("mobile_press_button", "Press a button on device", {
134
+ button: zod_1.z.string().describe("The button to press. Supported buttons: BACK (android only), HOME, VOLUME_UP, VOLUME_DOWN, ENTER"),
82
135
  }, async ({ button }) => {
83
- (0, child_process_1.execSync)(`adb shell input keyevent ${button}`);
136
+ requireRobot();
137
+ await robot.pressButton(button);
84
138
  return `Pressed the button: ${button}`;
85
139
  });
86
- tool("open_url", "Open a URL in browser on device", {
140
+ tool("mobile_open_url", "Open a URL in browser on device", {
87
141
  url: zod_1.z.string().describe("The URL to open"),
88
142
  }, async ({ url }) => {
89
- (0, child_process_1.execSync)(`adb shell am start -a android.intent.action.VIEW -d "${url}"`);
143
+ requireRobot();
144
+ await robot.openUrl(url);
90
145
  return `Opened URL: ${url}`;
91
146
  });
92
147
  tool("swipe_on_screen", "Swipe on the screen", {
93
148
  direction: zod_1.z.enum(["up", "down"]).describe("The direction to swipe"),
94
149
  }, async ({ direction }) => {
95
- (0, android_1.swipe)(direction);
150
+ requireRobot();
151
+ await robot.swipe(direction);
96
152
  return `Swiped ${direction} on screen`;
97
153
  });
98
- tool("type_text", "Type text into the focused element", {
154
+ tool("mobile_type_keys", "Type text into the focused element", {
99
155
  text: zod_1.z.string().describe("The text to type"),
100
- }, async ({ text }) => {
101
- const _text = text.replace(/ /g, "\\ ");
102
- (0, child_process_1.execSync)(`adb shell input text "${_text}"`);
156
+ submit: zod_1.z.boolean().describe("Whether to submit the text. If true, the text will be submitted as if the user pressed the enter key."),
157
+ }, async ({ text, submit }) => {
158
+ requireRobot();
159
+ await robot.sendKeys(text);
160
+ if (submit) {
161
+ await robot.pressButton("ENTER");
162
+ }
103
163
  return `Typed text: ${text}`;
104
164
  });
105
- server.tool("take_device_screenshot", "Take a screenshot of the mobile device. Use this to understand what's on screen, if you need to press an element that is available through view hierarchy then you must list elements on screen instead. Do not cache this result.", {}, async ({}) => {
165
+ server.tool("mobile_take_screenshot", "Take a screenshot of the mobile device. Use this to understand what's on screen, if you need to press an element that is available through view hierarchy then you must list elements on screen instead. Do not cache this result.", {}, async ({}) => {
166
+ requireRobot();
106
167
  try {
107
- const screenshot = await (0, android_1.takeScreenshot)();
168
+ const screenshot = await robot.getScreenshot();
108
169
  // Scale down the screenshot by 50%
109
170
  const image = (0, sharp_1.default)(screenshot);
110
171
  const metadata = await image.metadata();
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WebDriverAgent = void 0;
4
+ const robot_1 = require("./robot");
5
+ class WebDriverAgent {
6
+ host;
7
+ port;
8
+ constructor(host, port) {
9
+ this.host = host;
10
+ this.port = port;
11
+ }
12
+ async isRunning() {
13
+ const url = `http://${this.host}:${this.port}/status`;
14
+ try {
15
+ const response = await fetch(url);
16
+ return response.status === 200;
17
+ }
18
+ catch (error) {
19
+ console.error(`Failed to connect to WebDriverAgent: ${error}`);
20
+ return false;
21
+ }
22
+ }
23
+ async createSession() {
24
+ const url = `http://${this.host}:${this.port}/session`;
25
+ const response = await fetch(url, {
26
+ method: "POST",
27
+ headers: {
28
+ "Content-Type": "application/json",
29
+ },
30
+ body: JSON.stringify({ capabilities: { alwaysMatch: { platformName: "iOS" } } }),
31
+ });
32
+ const json = await response.json();
33
+ return json.value.sessionId;
34
+ }
35
+ async deleteSession(sessionId) {
36
+ const url = `http://${this.host}:${this.port}/session/${sessionId}`;
37
+ const response = await fetch(url, { method: "DELETE" });
38
+ return response.json();
39
+ }
40
+ async withinSession(fn) {
41
+ const sessionId = await this.createSession();
42
+ const url = `http://${this.host}:${this.port}/session/${sessionId}`;
43
+ const result = await fn(url);
44
+ await this.deleteSession(sessionId);
45
+ return result;
46
+ }
47
+ async getScreenSize() {
48
+ return this.withinSession(async (sessionUrl) => {
49
+ const url = `${sessionUrl}/wda/screen`;
50
+ const response = await fetch(url);
51
+ const json = await response.json();
52
+ return {
53
+ width: json.value.screenSize.width,
54
+ height: json.value.screenSize.height,
55
+ scale: json.value.scale || 1,
56
+ };
57
+ });
58
+ }
59
+ async sendKeys(keys) {
60
+ await this.withinSession(async (sessionUrl) => {
61
+ const url = `${sessionUrl}/wda/keys`;
62
+ await fetch(url, {
63
+ method: "POST",
64
+ headers: {
65
+ "Content-Type": "application/json",
66
+ },
67
+ body: JSON.stringify({ value: [keys] }),
68
+ });
69
+ });
70
+ }
71
+ async pressButton(button) {
72
+ const _map = {
73
+ "HOME": "home",
74
+ "VOLUME_UP": "volumeup",
75
+ "VOLUME_DOWN": "volumedown",
76
+ };
77
+ if (button === "ENTER") {
78
+ await this.sendKeys("\n");
79
+ return;
80
+ }
81
+ // Type assertion to check if button is a key of _map
82
+ if (!(button in _map)) {
83
+ throw new robot_1.ActionableError(`Button "${button}" is not supported`);
84
+ }
85
+ await this.withinSession(async (sessionUrl) => {
86
+ const url = `${sessionUrl}/wda/pressButton`;
87
+ const response = await fetch(url, {
88
+ method: "POST",
89
+ headers: {
90
+ "Content-Type": "application/json",
91
+ },
92
+ body: JSON.stringify({
93
+ name: button,
94
+ }),
95
+ });
96
+ return response.json();
97
+ });
98
+ }
99
+ async tap(x, y) {
100
+ await this.withinSession(async (sessionUrl) => {
101
+ const url = `${sessionUrl}/actions`;
102
+ await fetch(url, {
103
+ method: "POST",
104
+ headers: {
105
+ "Content-Type": "application/json",
106
+ },
107
+ body: JSON.stringify({
108
+ actions: [
109
+ {
110
+ type: "pointer",
111
+ id: "finger1",
112
+ parameters: { pointerType: "touch" },
113
+ actions: [
114
+ { type: "pointerMove", duration: 0, x, y },
115
+ { type: "pointerDown", button: 0 },
116
+ { type: "pause", duration: 100 },
117
+ { type: "pointerUp", button: 0 }
118
+ ]
119
+ }
120
+ ]
121
+ }),
122
+ });
123
+ });
124
+ }
125
+ isVisible(rect) {
126
+ return rect.x >= 0 && rect.y >= 0;
127
+ }
128
+ filterSourceElements(source) {
129
+ const output = [];
130
+ const acceptedTypes = ["TextField", "Button", "Switch", "Icon", "SearchField"];
131
+ if (acceptedTypes.includes(source.type)) {
132
+ if (source.isVisible === "1" && this.isVisible(source.rect)) {
133
+ if (source.label !== null || source.name !== null) {
134
+ output.push({
135
+ type: source.type,
136
+ label: source.label,
137
+ name: source.name,
138
+ value: source.value,
139
+ rect: {
140
+ x: source.rect.x,
141
+ y: source.rect.y,
142
+ width: source.rect.width,
143
+ height: source.rect.height,
144
+ },
145
+ });
146
+ }
147
+ }
148
+ }
149
+ if (source.children) {
150
+ for (const child of source.children) {
151
+ output.push(...this.filterSourceElements(child));
152
+ }
153
+ }
154
+ return output;
155
+ }
156
+ async getPageSource() {
157
+ const url = `http://${this.host}:${this.port}/source/?format=json`;
158
+ const response = await fetch(url);
159
+ const json = await response.json();
160
+ return json;
161
+ }
162
+ async getElementsOnScreen() {
163
+ const source = await this.getPageSource();
164
+ return this.filterSourceElements(source.value);
165
+ }
166
+ async openUrl(url) {
167
+ await this.withinSession(async (sessionUrl) => {
168
+ await fetch(`${sessionUrl}/url`, {
169
+ method: "POST",
170
+ body: JSON.stringify({ url }),
171
+ });
172
+ });
173
+ }
174
+ async swipe(direction) {
175
+ await this.withinSession(async (sessionUrl) => {
176
+ const x0 = 200;
177
+ let y0 = 600;
178
+ const x1 = 200;
179
+ let y1 = 200;
180
+ if (direction === "up") {
181
+ const tmp = y0;
182
+ y0 = y1;
183
+ y1 = tmp;
184
+ }
185
+ const url = `${sessionUrl}/actions`;
186
+ await fetch(url, {
187
+ method: "POST",
188
+ headers: {
189
+ "Content-Type": "application/json",
190
+ },
191
+ body: JSON.stringify({
192
+ actions: [
193
+ {
194
+ type: "pointer",
195
+ id: "finger1",
196
+ parameters: { pointerType: "touch" },
197
+ actions: [
198
+ { type: "pointerMove", duration: 0, x: x0, y: y0 },
199
+ { type: "pointerDown", button: 0 },
200
+ { type: "pointerMove", duration: 0, x: x1, y: y1 },
201
+ { type: "pause", duration: 1000 },
202
+ { type: "pointerUp", button: 0 }
203
+ ]
204
+ }
205
+ ]
206
+ }),
207
+ });
208
+ });
209
+ }
210
+ }
211
+ exports.WebDriverAgent = WebDriverAgent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobilenext/mobile-mcp",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "Mobile MCP",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,19 +13,18 @@
13
13
  "scripts": {
14
14
  "build": "tsc && chmod +x lib/index.js",
15
15
  "lint": "eslint .",
16
+ "test": "nyc mocha --require ts-node/register test/*.ts",
16
17
  "watch": "tsc --watch",
17
- "clean": "rm -rf lib"
18
- },
19
- "exports": {
20
- "./package.json": "./package.json",
21
- ".": {
22
- "types": "./index.d.ts",
23
- "default": "./index.js"
24
- }
18
+ "clean": "rm -rf lib",
19
+ "prepare": "husky"
25
20
  },
21
+ "files": [
22
+ "lib"
23
+ ],
26
24
  "dependencies": {
27
25
  "@modelcontextprotocol/sdk": "^1.6.1",
28
26
  "fast-xml-parser": "^5.0.9",
27
+ "nyc": "^17.1.0",
29
28
  "sharp": "^0.33.5",
30
29
  "zod-to-json-schema": "^3.24.4"
31
30
  },
@@ -33,6 +32,7 @@
33
32
  "@eslint/eslintrc": "^3.2.0",
34
33
  "@eslint/js": "^9.19.0",
35
34
  "@stylistic/eslint-plugin": "^3.0.1",
35
+ "@types/mocha": "^10.0.10",
36
36
  "@types/node": "^22.13.10",
37
37
  "@typescript-eslint/eslint-plugin": "^8.28.0",
38
38
  "@typescript-eslint/parser": "^8.26.1",
@@ -41,6 +41,9 @@
41
41
  "eslint-plugin": "^1.0.1",
42
42
  "eslint-plugin-import": "^2.31.0",
43
43
  "eslint-plugin-notice": "^1.0.0",
44
+ "husky": "^9.1.7",
45
+ "mocha": "^11.1.0",
46
+ "ts-node": "^10.9.2",
44
47
  "typescript": "^5.8.2"
45
48
  },
46
49
  "main": "index.js",