@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 +49 -22
- package/lib/android.js +148 -120
- package/lib/ios.js +158 -0
- package/lib/iphone-simulator.js +123 -36
- package/lib/robot.js +9 -0
- package/lib/server.js +101 -40
- package/lib/webdriver-agent.js +211 -0
- package/package.json +12 -9
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
|
-
##
|
|
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
|
-
[
|
|
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
|
-
##
|
|
111
|
-
- **Description:**
|
|
118
|
+
## mobile_list_apps
|
|
119
|
+
- **Description:** List all the installed apps on the device
|
|
112
120
|
- **Parameters:**
|
|
113
|
-
- `
|
|
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
|
-
- `
|
|
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
|
-
##
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
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
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
.
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
.
|
|
166
|
-
.filter(line => line.
|
|
167
|
-
.map(line => line.
|
|
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.
|
|
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;
|
package/lib/iphone-simulator.js
CHANGED
|
@@ -1,40 +1,127 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.SimctlManager = exports.Simctl = void 0;
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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("
|
|
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
|
-
(
|
|
96
|
+
requireRobot();
|
|
97
|
+
await robot.launchApp(packageName);
|
|
54
98
|
return `Launched app ${packageName}`;
|
|
55
99
|
});
|
|
56
|
-
tool("
|
|
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
|
-
(
|
|
103
|
+
requireRobot();
|
|
104
|
+
await robot.terminateApp(packageName);
|
|
60
105
|
return `Terminated app ${packageName}`;
|
|
61
106
|
});
|
|
62
|
-
tool("
|
|
63
|
-
|
|
64
|
-
|
|
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("
|
|
67
|
-
x: zod_1.z.number().describe("The x coordinate to click
|
|
68
|
-
y: zod_1.z.number().describe("The y coordinate to click
|
|
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
|
-
|
|
71
|
-
|
|
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("
|
|
77
|
-
|
|
78
|
-
|
|
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("
|
|
81
|
-
button: zod_1.z.string().describe("The button to press. Supported buttons:
|
|
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
|
-
(
|
|
136
|
+
requireRobot();
|
|
137
|
+
await robot.pressButton(button);
|
|
84
138
|
return `Pressed the button: ${button}`;
|
|
85
139
|
});
|
|
86
|
-
tool("
|
|
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
|
-
(
|
|
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
|
-
(
|
|
150
|
+
requireRobot();
|
|
151
|
+
await robot.swipe(direction);
|
|
96
152
|
return `Swiped ${direction} on screen`;
|
|
97
153
|
});
|
|
98
|
-
tool("
|
|
154
|
+
tool("mobile_type_keys", "Type text into the focused element", {
|
|
99
155
|
text: zod_1.z.string().describe("The text to type"),
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
(
|
|
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("
|
|
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
|
|
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.
|
|
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",
|