@mobilenext/mobile-mcp 0.0.10 → 0.0.11
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 +50 -7
- package/lib/android.js +147 -120
- package/lib/ios.js +79 -0
- package/lib/iphone-simulator.js +160 -27
- package/lib/robot.js +2 -0
- package/lib/server.js +107 -31
- package/lib/webdriver-agent.js +145 -0
- package/package.json +11 -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,7 +73,8 @@ 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
|
```
|
|
@@ -112,6 +119,11 @@ The commands and tools support both accessibility-based locators (preferred) and
|
|
|
112
119
|
- **Parameters:**
|
|
113
120
|
- `appPath` (string): Path or URL to the app file (e.g., .apk for Android, .ipa/.app for iOS)
|
|
114
121
|
|
|
122
|
+
## mobile_list_apps
|
|
123
|
+
- **Description:** List all the installed apps on the device
|
|
124
|
+
- **Parameters:**
|
|
125
|
+
- `bundleId` (string): The application's unique bundle/package identifier like: com.google.android.keep or com.apple.mobilenotes )
|
|
126
|
+
|
|
115
127
|
## mobile_launch_app
|
|
116
128
|
- **Description:** Launches the specified app on the device/emulator
|
|
117
129
|
- **Parameters:**
|
|
@@ -120,7 +132,21 @@ The commands and tools support both accessibility-based locators (preferred) and
|
|
|
120
132
|
## mobile_terminate_app
|
|
121
133
|
- **Description:** Terminates a running application
|
|
122
134
|
- **Parameters:**
|
|
123
|
-
- `
|
|
135
|
+
- `packageName` (string): Based on the application's bundle/package identifier calls am force stop or kills the app based on pid.
|
|
136
|
+
|
|
137
|
+
## mobile_get_screen_size
|
|
138
|
+
- **Description:** Get the screen size of the mobile device in pixels
|
|
139
|
+
- **Parameters:** None
|
|
140
|
+
|
|
141
|
+
## mobile_click_on_screen_at_coordinates
|
|
142
|
+
- **Description:** Taps on specified screen coordinates based on coordinates.
|
|
143
|
+
- **Parameters:**
|
|
144
|
+
- `x` (number): X-coordinate
|
|
145
|
+
- `y` (number): Y-coordinate
|
|
146
|
+
|
|
147
|
+
## mobile_list_elements_on_screen
|
|
148
|
+
- **Description:** List elements on screen and their coordinates, with display text or accessibility label.
|
|
149
|
+
- **Parameters:** None
|
|
124
150
|
|
|
125
151
|
## mobile_element_tap
|
|
126
152
|
- **Description:** Taps on a UI element identified by accessibility locator
|
|
@@ -133,9 +159,18 @@ The commands and tools support both accessibility-based locators (preferred) and
|
|
|
133
159
|
- **Parameters:**
|
|
134
160
|
- `x` (number): X-coordinate
|
|
135
161
|
- `y` (number): Y-coordinate
|
|
162
|
+
|
|
163
|
+
## mobile_press_button
|
|
164
|
+
- **Description:** Press a button on device (home, back, volume, enter, power button.)
|
|
165
|
+
- **Parameters:** None
|
|
136
166
|
|
|
137
|
-
##
|
|
138
|
-
- **Description:**
|
|
167
|
+
## mobile_open_url
|
|
168
|
+
- **Description:** Open a URL in browser on device
|
|
169
|
+
- **Parameters:**
|
|
170
|
+
- `url` (string): The URL to be opened (e.g., "https://example.com").
|
|
171
|
+
|
|
172
|
+
## mobile_type_text
|
|
173
|
+
- **Description:** Types text into a focused UI element (e.g., TextField, SearchField)
|
|
139
174
|
- **Parameters:**
|
|
140
175
|
- `element` (string): Human-readable element description
|
|
141
176
|
- `ref` (string): Accessibility/automation ID of the element
|
|
@@ -182,3 +217,11 @@ The commands and tools support both accessibility-based locators (preferred) and
|
|
|
182
217
|
- **Parameters:** None
|
|
183
218
|
|
|
184
219
|
|
|
220
|
+
# Thanks to all contributors ❤️
|
|
221
|
+
|
|
222
|
+
### We appreciate everyone who has helped improve this project.
|
|
223
|
+
|
|
224
|
+
<a href = "https://github.com/mobile-next/mobile-mcp/graphs/contributors">
|
|
225
|
+
<img src = "https://contrib.rocks/image?repo=mobile-next/mobile-mcp"/>
|
|
226
|
+
</a>
|
|
227
|
+
|
package/lib/android.js
CHANGED
|
@@ -32,140 +32,167 @@ 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;
|
|
37
40
|
const child_process_1 = require("child_process");
|
|
38
41
|
const xml = __importStar(require("fast-xml-parser"));
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.
|
|
44
|
-
.filter(line => !line.startsWith("List of devices attached"))
|
|
45
|
-
.filter(line => line.trim() !== "");
|
|
46
|
-
};
|
|
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));
|
|
55
|
-
};
|
|
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");
|
|
42
|
+
const path_1 = __importDefault(require("path"));
|
|
43
|
+
class AndroidRobot {
|
|
44
|
+
deviceId;
|
|
45
|
+
constructor(deviceId) {
|
|
46
|
+
this.deviceId = deviceId;
|
|
64
47
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const [,
|
|
74
|
-
return {
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
48
|
+
async getScreenSize() {
|
|
49
|
+
const screenSize = (0, child_process_1.execSync)(`adb -s ${this.deviceId} shell wm size`)
|
|
50
|
+
.toString()
|
|
51
|
+
.split(" ")
|
|
52
|
+
.pop();
|
|
53
|
+
if (!screenSize) {
|
|
54
|
+
throw new Error("Failed to get screen size");
|
|
55
|
+
}
|
|
56
|
+
const [width, height] = screenSize.split("x").map(Number);
|
|
57
|
+
return { width, height };
|
|
58
|
+
}
|
|
59
|
+
adb(...args) {
|
|
60
|
+
let executable = "adb";
|
|
61
|
+
if (process.env.ANDROID_HOME) {
|
|
62
|
+
executable = path_1.default.join(process.env.ANDROID_HOME, "platform-tools", "adb");
|
|
63
|
+
}
|
|
64
|
+
return (0, child_process_1.execFileSync)(executable, ["-s", this.deviceId, ...args], {
|
|
65
|
+
maxBuffer: 1024 * 1024 * 4,
|
|
66
|
+
timeout: 30000,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async listApps() {
|
|
70
|
+
const result = this.adb("shell", "cmd", "package", "query-activities", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER")
|
|
71
|
+
.toString()
|
|
72
|
+
.split("\n")
|
|
73
|
+
.map(line => line.trim())
|
|
74
|
+
.filter(line => line.startsWith("packageName="))
|
|
75
|
+
.map(line => line.substring("packageName=".length))
|
|
76
|
+
.filter((value, index, self) => self.indexOf(value) === index);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
async launchApp(packageName) {
|
|
80
|
+
this.adb("shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1");
|
|
81
|
+
}
|
|
82
|
+
async swipe(direction) {
|
|
83
|
+
const screenSize = await this.getScreenSize();
|
|
84
|
+
const centerX = screenSize.width >> 1;
|
|
85
|
+
// const centerY = screenSize[1] >> 1;
|
|
86
|
+
let x0, y0, x1, y1;
|
|
87
|
+
switch (direction) {
|
|
88
|
+
case "up":
|
|
89
|
+
x0 = x1 = centerX;
|
|
90
|
+
y0 = Math.floor(screenSize.height * 0.80);
|
|
91
|
+
y1 = Math.floor(screenSize.height * 0.20);
|
|
92
|
+
break;
|
|
93
|
+
case "down":
|
|
94
|
+
x0 = x1 = centerX;
|
|
95
|
+
y0 = Math.floor(screenSize.height * 0.20);
|
|
96
|
+
y1 = Math.floor(screenSize.height * 0.80);
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
throw new Error(`Swipe direction "${direction}" is not supported`);
|
|
100
|
+
}
|
|
101
|
+
this.adb("shell", "input", "swipe", `${x0}`, `${y0}`, `${x1}`, `${y1}`, "1000");
|
|
102
|
+
}
|
|
103
|
+
async getScreenshot() {
|
|
104
|
+
return this.adb("shell", "screencap", "-p");
|
|
105
|
+
}
|
|
106
|
+
collectElements(node, screenSize) {
|
|
107
|
+
const elements = [];
|
|
108
|
+
const getCoordinates = (element) => {
|
|
109
|
+
const bounds = String(element.bounds);
|
|
110
|
+
const [, left, top, right, bottom] = bounds.match(/^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$/)?.map(Number) || [];
|
|
111
|
+
return { left, top, right, bottom };
|
|
80
112
|
};
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
const getCenter = (coordinates) => {
|
|
114
|
+
return {
|
|
115
|
+
x: Math.floor((coordinates.left + coordinates.right) / 2),
|
|
116
|
+
y: Math.floor((coordinates.top + coordinates.bottom) / 2),
|
|
117
|
+
};
|
|
86
118
|
};
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
119
|
+
const normalizeCoordinates = (coordinates, screenSize) => {
|
|
120
|
+
return {
|
|
121
|
+
x: Number((coordinates.x / screenSize.width).toFixed(3)),
|
|
122
|
+
y: Number((coordinates.y / screenSize.height).toFixed(3)),
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
if (node.node) {
|
|
126
|
+
if (Array.isArray(node.node)) {
|
|
127
|
+
for (const childNode of node.node) {
|
|
128
|
+
elements.push(...this.collectElements(childNode, screenSize));
|
|
129
|
+
}
|
|
92
130
|
}
|
|
131
|
+
else {
|
|
132
|
+
elements.push(...this.collectElements(node.node, screenSize));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (node.text) {
|
|
136
|
+
elements.push({
|
|
137
|
+
"text": node.text,
|
|
138
|
+
"coordinates": normalizeCoordinates(getCenter(getCoordinates(node)), screenSize),
|
|
139
|
+
});
|
|
93
140
|
}
|
|
94
|
-
|
|
95
|
-
elements.push(
|
|
141
|
+
if (node["content-desc"]) {
|
|
142
|
+
elements.push({
|
|
143
|
+
"text": node["content-desc"],
|
|
144
|
+
"coordinates": normalizeCoordinates(getCenter(getCoordinates(node)), screenSize),
|
|
145
|
+
});
|
|
96
146
|
}
|
|
147
|
+
return elements;
|
|
97
148
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
149
|
+
async getElementsOnScreen() {
|
|
150
|
+
const dump = this.adb("exec-out", "uiautomator", "dump", "/dev/tty");
|
|
151
|
+
const parser = new xml.XMLParser({
|
|
152
|
+
ignoreAttributes: false,
|
|
153
|
+
attributeNamePrefix: ""
|
|
102
154
|
});
|
|
155
|
+
const parsedXml = parser.parse(dump);
|
|
156
|
+
const hierarchy = parsedXml.hierarchy;
|
|
157
|
+
const screenSize = await this.getScreenSize();
|
|
158
|
+
const elements = this.collectElements(hierarchy.node, screenSize);
|
|
159
|
+
return elements;
|
|
103
160
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"text": node["content-desc"],
|
|
107
|
-
"coordinates": normalizeCoordinates(getCenter(getCoordinates(node)), screenSize),
|
|
108
|
-
});
|
|
161
|
+
async terminateApp(packageName) {
|
|
162
|
+
this.adb("shell", "am", "force-stop", packageName);
|
|
109
163
|
}
|
|
110
|
-
|
|
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`);
|
|
164
|
+
async openUrl(url) {
|
|
165
|
+
this.adb("shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", url);
|
|
143
166
|
}
|
|
144
|
-
(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
async sendKeys(text) {
|
|
168
|
+
// adb shell requires some escaping
|
|
169
|
+
const _text = text.replace(/ /g, "\\ ");
|
|
170
|
+
this.adb("shell", "input", "text", _text);
|
|
171
|
+
}
|
|
172
|
+
async pressButton(button) {
|
|
173
|
+
const _map = {
|
|
174
|
+
"BACK": "KEYCODE_BACK",
|
|
175
|
+
"HOME": "KEYCODE_HOME",
|
|
176
|
+
"VOLUME_UP": "KEYCODE_VOLUME_UP",
|
|
177
|
+
"VOLUME_DOWN": "KEYCODE_VOLUME_DOWN",
|
|
178
|
+
"ENTER": "KEYCODE_ENTER",
|
|
179
|
+
};
|
|
180
|
+
if (!_map[button]) {
|
|
181
|
+
throw new Error(`Button "${button}" is not supported`);
|
|
182
|
+
}
|
|
183
|
+
this.adb("shell", "input", "keyevent", _map[button]);
|
|
184
|
+
}
|
|
185
|
+
async tap(x, y) {
|
|
186
|
+
this.adb("shell", "input", "tap", `${x}`, `${y}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
exports.AndroidRobot = AndroidRobot;
|
|
190
|
+
const getConnectedDevices = () => {
|
|
191
|
+
return (0, child_process_1.execSync)(`adb devices`)
|
|
163
192
|
.toString()
|
|
164
193
|
.split("\n")
|
|
165
|
-
.
|
|
166
|
-
.filter(line => line.
|
|
167
|
-
.map(line => line.
|
|
168
|
-
.filter((value, index, self) => self.indexOf(value) === index);
|
|
169
|
-
return result;
|
|
194
|
+
.filter(line => !line.startsWith("List of devices attached"))
|
|
195
|
+
.filter(line => line.trim() !== "")
|
|
196
|
+
.map(line => line.split("\t")[0]);
|
|
170
197
|
};
|
|
171
|
-
exports.
|
|
198
|
+
exports.getConnectedDevices = getConnectedDevices;
|
package/lib/ios.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IosManager = exports.IosRobot = void 0;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
const webdriver_agent_1 = require("./webdriver-agent");
|
|
7
|
+
class IosRobot {
|
|
8
|
+
deviceId;
|
|
9
|
+
wda;
|
|
10
|
+
constructor(deviceId) {
|
|
11
|
+
this.deviceId = deviceId;
|
|
12
|
+
this.wda = new webdriver_agent_1.WebDriverAgent("localhost", 8100);
|
|
13
|
+
}
|
|
14
|
+
async ios(...args) {
|
|
15
|
+
return (0, child_process_1.execFileSync)("ios", ["--udid", this.deviceId, ...args], {}).toString();
|
|
16
|
+
}
|
|
17
|
+
async getScreenSize() {
|
|
18
|
+
return await this.wda.getScreenSize();
|
|
19
|
+
}
|
|
20
|
+
swipe(direction) {
|
|
21
|
+
return Promise.resolve();
|
|
22
|
+
}
|
|
23
|
+
async listApps() {
|
|
24
|
+
const output = await this.ios("apps", "--all", "--list");
|
|
25
|
+
return output
|
|
26
|
+
.split("\n")
|
|
27
|
+
.map(line => line.split(" ")[0]);
|
|
28
|
+
}
|
|
29
|
+
async launchApp(packageName) {
|
|
30
|
+
await this.ios("launch", packageName);
|
|
31
|
+
}
|
|
32
|
+
async terminateApp(packageName) {
|
|
33
|
+
await this.ios("kill", packageName);
|
|
34
|
+
}
|
|
35
|
+
async openUrl(url) {
|
|
36
|
+
await this.wda.withinSession(async (sessionUrl) => {
|
|
37
|
+
await fetch(`${sessionUrl}/url`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
body: JSON.stringify({ url }),
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async sendKeys(text) {
|
|
44
|
+
await this.wda.sendKeys(text);
|
|
45
|
+
}
|
|
46
|
+
async pressButton(button) {
|
|
47
|
+
await this.wda.pressButton(button);
|
|
48
|
+
}
|
|
49
|
+
async tap(x, y) {
|
|
50
|
+
await this.wda.tap(x, y);
|
|
51
|
+
}
|
|
52
|
+
async getElementsOnScreen() {
|
|
53
|
+
return await this.wda.getElementsOnScreen();
|
|
54
|
+
}
|
|
55
|
+
async getScreenshot() {
|
|
56
|
+
await this.ios("screenshot", "--output", "screenshot.png");
|
|
57
|
+
const buffer = (0, fs_1.readFileSync)("screenshot.png");
|
|
58
|
+
(0, fs_1.unlinkSync)("screenshot.png");
|
|
59
|
+
return buffer;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.IosRobot = IosRobot;
|
|
63
|
+
class IosManager {
|
|
64
|
+
async listDevices() {
|
|
65
|
+
const output = (0, child_process_1.execSync)("ios list").toString();
|
|
66
|
+
const json = JSON.parse(output);
|
|
67
|
+
return json.deviceList;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.IosManager = IosManager;
|
|
71
|
+
async function main() {
|
|
72
|
+
const ios = new IosRobot("4C07ED7E-AE81-412E-8AA9-1061EED59DFA");
|
|
73
|
+
const before = +new Date();
|
|
74
|
+
console.dir(await ios.getElementsOnScreen(), { depth: null });
|
|
75
|
+
const after = +new Date();
|
|
76
|
+
console.log(`Time taken: ${after - before}ms`);
|
|
77
|
+
// await ios.pressButton("VOLUME_UP");
|
|
78
|
+
}
|
|
79
|
+
main().then();
|
package/lib/iphone-simulator.js
CHANGED
|
@@ -1,40 +1,173 @@
|
|
|
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
|
-
|
|
5
|
+
const webdriver_agent_1 = require("./webdriver-agent");
|
|
6
|
+
class Simctl {
|
|
7
|
+
simulatorUuid;
|
|
8
|
+
webDriverAgent;
|
|
9
|
+
constructor(simulatorUuid) {
|
|
10
|
+
this.simulatorUuid = simulatorUuid;
|
|
11
|
+
this.webDriverAgent = new webdriver_agent_1.WebDriverAgent("localhost", 8100);
|
|
12
|
+
}
|
|
13
|
+
simctl(...args) {
|
|
14
|
+
return (0, child_process_1.execFileSync)("xcrun", ["simctl", ...args], { maxBuffer: 1024 * 1024 * 4 });
|
|
15
|
+
}
|
|
16
|
+
async getScreenshot() {
|
|
17
|
+
return this.simctl("io", this.simulatorUuid, "screenshot", "-");
|
|
18
|
+
}
|
|
19
|
+
async openUrl(url) {
|
|
20
|
+
this.simctl("openurl", this.simulatorUuid, url);
|
|
21
|
+
}
|
|
22
|
+
async launchApp(packageName) {
|
|
23
|
+
this.simctl("launch", this.simulatorUuid, packageName);
|
|
24
|
+
}
|
|
25
|
+
async terminateApp(packageName) {
|
|
26
|
+
this.simctl("terminate", this.simulatorUuid, packageName);
|
|
27
|
+
}
|
|
28
|
+
parseIOSAppData(inputText) {
|
|
29
|
+
const result = [];
|
|
30
|
+
// Remove leading and trailing characters if needed
|
|
31
|
+
const cleanText = inputText.trim();
|
|
32
|
+
// Extract each app section
|
|
33
|
+
const appRegex = /"([^"]+)"\s+=\s+\{([^}]+)\};/g;
|
|
34
|
+
let appMatch;
|
|
35
|
+
while ((appMatch = appRegex.exec(cleanText)) !== null) {
|
|
36
|
+
// const bundleId = appMatch[1];
|
|
37
|
+
const appContent = appMatch[2];
|
|
38
|
+
const appInfo = {
|
|
39
|
+
GroupContainers: {},
|
|
40
|
+
SBAppTags: []
|
|
41
|
+
};
|
|
42
|
+
// parse simple key-value pairs
|
|
43
|
+
const keyValueRegex = /\s+(\w+)\s+=\s+([^;]+);/g;
|
|
44
|
+
let keyValueMatch;
|
|
45
|
+
while ((keyValueMatch = keyValueRegex.exec(appContent)) !== null) {
|
|
46
|
+
const key = keyValueMatch[1];
|
|
47
|
+
let value = keyValueMatch[2].trim();
|
|
48
|
+
// Handle quoted string values
|
|
49
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
50
|
+
value = value.substring(1, value.length - 1);
|
|
51
|
+
}
|
|
52
|
+
if (key !== "GroupContainers" && key !== "SBAppTags") {
|
|
53
|
+
appInfo[key] = value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// parse GroupContainers
|
|
57
|
+
const groupContainersMatch = appContent.match(/GroupContainers\s+=\s+\{([^}]+)\};/);
|
|
58
|
+
if (groupContainersMatch) {
|
|
59
|
+
const groupContainersContent = groupContainersMatch[1];
|
|
60
|
+
const groupRegex = /"([^"]+)"\s+=\s+"([^"]+)"/g;
|
|
61
|
+
let groupMatch;
|
|
62
|
+
while ((groupMatch = groupRegex.exec(groupContainersContent)) !== null) {
|
|
63
|
+
const groupId = groupMatch[1];
|
|
64
|
+
const groupPath = groupMatch[2];
|
|
65
|
+
appInfo.GroupContainers[groupId] = groupPath;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// parse SBAppTags
|
|
69
|
+
const sbAppTagsMatch = appContent.match(/SBAppTags\s+=\s+\(\s*(.*?)\s*\);/);
|
|
70
|
+
if (sbAppTagsMatch) {
|
|
71
|
+
const tagsContent = sbAppTagsMatch[1].trim();
|
|
72
|
+
if (tagsContent) {
|
|
73
|
+
const tagRegex = /"([^"]+)"/g;
|
|
74
|
+
let tagMatch;
|
|
75
|
+
while ((tagMatch = tagRegex.exec(tagsContent)) !== null) {
|
|
76
|
+
appInfo.SBAppTags.push(tagMatch[1]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
result.push(appInfo);
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
async listApps() {
|
|
85
|
+
const text = this.simctl("listapps", this.simulatorUuid).toString();
|
|
86
|
+
const apps = this.parseIOSAppData(text);
|
|
87
|
+
return apps.map(app => app.CFBundleIdentifier);
|
|
88
|
+
}
|
|
89
|
+
async getScreenSize() {
|
|
90
|
+
return this.webDriverAgent.getScreenSize();
|
|
91
|
+
}
|
|
92
|
+
async sendKeys(keys) {
|
|
93
|
+
return this.webDriverAgent.sendKeys(keys);
|
|
94
|
+
}
|
|
95
|
+
async swipe(direction) {
|
|
96
|
+
await this.webDriverAgent.withinSession(async (sessionUrl) => {
|
|
97
|
+
const x0 = 200;
|
|
98
|
+
let y0 = 600;
|
|
99
|
+
const x1 = 200;
|
|
100
|
+
let y1 = 200;
|
|
101
|
+
if (direction === "up") {
|
|
102
|
+
const tmp = y0;
|
|
103
|
+
y0 = y1;
|
|
104
|
+
y1 = tmp;
|
|
105
|
+
}
|
|
106
|
+
const url = `${sessionUrl}/actions`;
|
|
107
|
+
await fetch(url, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
actions: [
|
|
114
|
+
{
|
|
115
|
+
type: "pointer",
|
|
116
|
+
id: "finger1",
|
|
117
|
+
parameters: { pointerType: "touch" },
|
|
118
|
+
actions: [
|
|
119
|
+
{ type: "pointerMove", duration: 0, x: x0, y: y0 },
|
|
120
|
+
{ type: "pointerDown", button: 0 },
|
|
121
|
+
{ type: "pointerMove", duration: 0, x: x1, y: y1 },
|
|
122
|
+
{ type: "pause", duration: 1000 },
|
|
123
|
+
{ type: "pointerUp", button: 0 }
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async tap(x, y) {
|
|
132
|
+
await this.webDriverAgent.tap(x, y);
|
|
133
|
+
}
|
|
134
|
+
async pressButton(button) {
|
|
135
|
+
await this.webDriverAgent.pressButton(button);
|
|
136
|
+
}
|
|
137
|
+
async getElementsOnScreen() {
|
|
138
|
+
return await this.webDriverAgent.getElementsOnScreen();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
exports.Simctl = Simctl;
|
|
142
|
+
class SimctlManager {
|
|
143
|
+
parseSimulator(line) {
|
|
10
144
|
// extract device name and UUID from the line
|
|
11
|
-
const match = line.match(/(.*?)\s+\(([\w-]+)\)\s+\(
|
|
145
|
+
const match = line.match(/(.*?)\s+\(([\w-]+)\)\s+\((\w+)\)/);
|
|
12
146
|
if (!match) {
|
|
13
147
|
return null;
|
|
14
148
|
}
|
|
15
149
|
const deviceName = match[1].trim();
|
|
16
150
|
const deviceUuid = match[2];
|
|
151
|
+
const deviceState = match[3];
|
|
17
152
|
return {
|
|
18
153
|
name: deviceName,
|
|
19
154
|
uuid: deviceUuid,
|
|
155
|
+
state: deviceState,
|
|
20
156
|
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return (0, child_process_1.execSync)(`xcrun simctl list apps "${simulatorUuid}"`);
|
|
39
|
-
};
|
|
40
|
-
exports.listApps = listApps;
|
|
157
|
+
}
|
|
158
|
+
listSimulators() {
|
|
159
|
+
return (0, child_process_1.execSync)(`xcrun simctl list devices`)
|
|
160
|
+
.toString()
|
|
161
|
+
.split("\n")
|
|
162
|
+
.map(line => this.parseSimulator(line))
|
|
163
|
+
.filter(simulator => simulator !== null);
|
|
164
|
+
}
|
|
165
|
+
listBootedSimulators() {
|
|
166
|
+
return this.listSimulators()
|
|
167
|
+
.filter(simulator => simulator.state === "Booted");
|
|
168
|
+
}
|
|
169
|
+
getSimulator(uuid) {
|
|
170
|
+
return new Simctl(uuid);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
exports.SimctlManager = SimctlManager;
|
package/lib/robot.js
ADDED
package/lib/server.js
CHANGED
|
@@ -5,11 +5,12 @@ 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 iphone_simulator_1 = require("./iphone-simulator");
|
|
13
|
+
const ios_1 = require("./ios");
|
|
13
14
|
const getAgentVersion = () => {
|
|
14
15
|
const json = require("../package.json");
|
|
15
16
|
return json.version;
|
|
@@ -43,68 +44,143 @@ const createMcpServer = () => {
|
|
|
43
44
|
};
|
|
44
45
|
server.tool(name, description, paramsSchema, args => wrappedCb(args));
|
|
45
46
|
};
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
let robot;
|
|
48
|
+
const simulatorManager = new iphone_simulator_1.SimctlManager();
|
|
49
|
+
tool("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 ({}) => {
|
|
50
|
+
const iosManager = new ios_1.IosManager();
|
|
51
|
+
const devices = await simulatorManager.listBootedSimulators();
|
|
52
|
+
const simulatorNames = devices.map(d => d.name);
|
|
53
|
+
const androidDevices = (0, android_1.getConnectedDevices)();
|
|
54
|
+
const iosDevices = await iosManager.listDevices();
|
|
55
|
+
return `Found these iOS simulators: [${simulatorNames.join(".")}], iOS devices: [${iosDevices.join(",")}] and Android devices: [${androidDevices.join(",")}]`;
|
|
56
|
+
});
|
|
57
|
+
tool("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.", {
|
|
58
|
+
device: zod_1.z.string().describe("The name of the device to select"),
|
|
59
|
+
deviceType: zod_1.z.enum(["simulator", "ios", "android"]).describe("The type of device to select"),
|
|
60
|
+
}, async ({ device, deviceType }) => {
|
|
61
|
+
console.log(device, deviceType);
|
|
62
|
+
switch (deviceType) {
|
|
63
|
+
case "simulator":
|
|
64
|
+
robot = simulatorManager.getSimulator(device);
|
|
65
|
+
break;
|
|
66
|
+
case "ios":
|
|
67
|
+
robot = new ios_1.IosRobot(device);
|
|
68
|
+
break;
|
|
69
|
+
case "android":
|
|
70
|
+
robot = new android_1.AndroidRobot(device);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
return `Selected device: ${device} (${deviceType})`;
|
|
74
|
+
});
|
|
75
|
+
tool("mobile_list_apps", "List all the installed apps on the device", {}, async ({}) => {
|
|
76
|
+
if (!robot) {
|
|
77
|
+
throw new Error("No device selected");
|
|
78
|
+
}
|
|
79
|
+
const result = await robot.listApps();
|
|
48
80
|
return `Found these packages on device: ${result.join(",")}`;
|
|
49
81
|
});
|
|
50
|
-
tool("
|
|
82
|
+
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
83
|
packageName: zod_1.z.string().describe("The package name of the app to launch"),
|
|
52
84
|
}, async ({ packageName }) => {
|
|
53
|
-
(
|
|
85
|
+
if (!robot) {
|
|
86
|
+
throw new Error("No device selected");
|
|
87
|
+
}
|
|
88
|
+
await robot.launchApp(packageName);
|
|
54
89
|
return `Launched app ${packageName}`;
|
|
55
90
|
});
|
|
56
|
-
tool("
|
|
91
|
+
tool("mobile_terminate_app", "Stop and terminate an app on mobile device", {
|
|
57
92
|
packageName: zod_1.z.string().describe("The package name of the app to terminate"),
|
|
58
93
|
}, async ({ packageName }) => {
|
|
59
|
-
(
|
|
94
|
+
if (!robot) {
|
|
95
|
+
throw new Error("No device selected");
|
|
96
|
+
}
|
|
97
|
+
await robot.terminateApp(packageName);
|
|
60
98
|
return `Terminated app ${packageName}`;
|
|
61
99
|
});
|
|
62
|
-
tool("
|
|
63
|
-
|
|
64
|
-
|
|
100
|
+
tool("mobile_get_screen_size", "Get the screen size of the mobile device in pixels", {}, async ({}) => {
|
|
101
|
+
if (!robot) {
|
|
102
|
+
throw new Error("No device selected");
|
|
103
|
+
}
|
|
104
|
+
const screenSize = await robot.getScreenSize();
|
|
105
|
+
return `Screen size is ${screenSize.width}x${screenSize.height} pixels`;
|
|
65
106
|
});
|
|
66
|
-
tool("
|
|
107
|
+
tool("mobile_click_on_screen_at_coordinates", "Click on the screen at given x,y coordinates", {
|
|
67
108
|
x: zod_1.z.number().describe("The x coordinate to click between 0 and 1"),
|
|
68
109
|
y: zod_1.z.number().describe("The y coordinate to click between 0 and 1"),
|
|
69
110
|
}, async ({ x, y }) => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
111
|
+
if (!robot) {
|
|
112
|
+
throw new Error("No device selected");
|
|
113
|
+
}
|
|
114
|
+
const screenSize = await robot.getScreenSize();
|
|
115
|
+
const x0 = Math.floor(screenSize.width * x);
|
|
116
|
+
const y0 = Math.floor(screenSize.height * y);
|
|
117
|
+
await robot.tap(x0, y0);
|
|
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
|
+
if (!robot) {
|
|
122
|
+
throw new Error("No device selected");
|
|
123
|
+
}
|
|
124
|
+
const screenSize = await robot.getScreenSize();
|
|
125
|
+
const elements = await robot.getElementsOnScreen();
|
|
126
|
+
const result = [];
|
|
127
|
+
for (let i = 0; i < elements.length; i++) {
|
|
128
|
+
elements[i].rect.x0 = elements[i].rect.x0 / screenSize.width;
|
|
129
|
+
elements[i].rect.y0 = elements[i].rect.y0 / screenSize.height;
|
|
130
|
+
elements[i].rect.x1 = elements[i].rect.x1 / screenSize.width;
|
|
131
|
+
elements[i].rect.y1 = elements[i].rect.y1 / screenSize.height;
|
|
132
|
+
result.push({
|
|
133
|
+
text: elements[i].label,
|
|
134
|
+
coordinates: {
|
|
135
|
+
x: (elements[i].rect.x0 + elements[i].rect.x1) / 2,
|
|
136
|
+
y: (elements[i].rect.y0 + elements[i].rect.y1) / 2,
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return `Found these elements on screen: ${JSON.stringify(result)}`;
|
|
79
141
|
});
|
|
80
|
-
tool("
|
|
81
|
-
button: zod_1.z.string().describe("The button to press. Supported buttons:
|
|
142
|
+
tool("mobile_press_button", "Press a button on device", {
|
|
143
|
+
button: zod_1.z.string().describe("The button to press. Supported buttons: BACK, HOME, VOLUME_UP, VOLUME_DOWN, ENTER"),
|
|
82
144
|
}, async ({ button }) => {
|
|
83
|
-
(
|
|
145
|
+
if (!robot) {
|
|
146
|
+
throw new Error("No device selected");
|
|
147
|
+
}
|
|
148
|
+
robot.pressButton(button);
|
|
84
149
|
return `Pressed the button: ${button}`;
|
|
85
150
|
});
|
|
86
|
-
tool("
|
|
151
|
+
tool("mobile_open_url", "Open a URL in browser on device", {
|
|
87
152
|
url: zod_1.z.string().describe("The URL to open"),
|
|
88
153
|
}, async ({ url }) => {
|
|
89
|
-
(
|
|
154
|
+
if (!robot) {
|
|
155
|
+
throw new Error("No device selected");
|
|
156
|
+
}
|
|
157
|
+
robot.openUrl(url);
|
|
90
158
|
return `Opened URL: ${url}`;
|
|
91
159
|
});
|
|
92
160
|
tool("swipe_on_screen", "Swipe on the screen", {
|
|
93
161
|
direction: zod_1.z.enum(["up", "down"]).describe("The direction to swipe"),
|
|
94
162
|
}, async ({ direction }) => {
|
|
95
|
-
|
|
163
|
+
if (!robot) {
|
|
164
|
+
throw new Error("No device selected");
|
|
165
|
+
}
|
|
166
|
+
robot.swipe(direction);
|
|
96
167
|
return `Swiped ${direction} on screen`;
|
|
97
168
|
});
|
|
98
|
-
tool("
|
|
169
|
+
tool("mobile_type_keys", "Type text into the focused element", {
|
|
99
170
|
text: zod_1.z.string().describe("The text to type"),
|
|
100
171
|
}, async ({ text }) => {
|
|
101
|
-
|
|
102
|
-
|
|
172
|
+
if (!robot) {
|
|
173
|
+
throw new Error("No device selected");
|
|
174
|
+
}
|
|
175
|
+
robot.sendKeys(text);
|
|
103
176
|
return `Typed text: ${text}`;
|
|
104
177
|
});
|
|
105
|
-
server.tool("
|
|
178
|
+
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 ({}) => {
|
|
179
|
+
if (!robot) {
|
|
180
|
+
throw new Error("No device selected");
|
|
181
|
+
}
|
|
106
182
|
try {
|
|
107
|
-
const screenshot = await
|
|
183
|
+
const screenshot = await robot.getScreenshot();
|
|
108
184
|
// Scale down the screenshot by 50%
|
|
109
185
|
const image = (0, sharp_1.default)(screenshot);
|
|
110
186
|
const metadata = await image.metadata();
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WebDriverAgent = void 0;
|
|
4
|
+
class WebDriverAgent {
|
|
5
|
+
host;
|
|
6
|
+
port;
|
|
7
|
+
constructor(host, port) {
|
|
8
|
+
this.host = host;
|
|
9
|
+
this.port = port;
|
|
10
|
+
}
|
|
11
|
+
async createSession() {
|
|
12
|
+
const url = `http://${this.host}:${this.port}/session`;
|
|
13
|
+
const response = await fetch(url, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: {
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
},
|
|
18
|
+
body: JSON.stringify({ capabilities: { alwaysMatch: { platformName: "iOS" } } }),
|
|
19
|
+
});
|
|
20
|
+
const json = await response.json();
|
|
21
|
+
return json.value.sessionId;
|
|
22
|
+
}
|
|
23
|
+
async deleteSession(sessionId) {
|
|
24
|
+
const url = `http://${this.host}:${this.port}/session/${sessionId}`;
|
|
25
|
+
const response = await fetch(url, { method: "DELETE" });
|
|
26
|
+
return response.json();
|
|
27
|
+
}
|
|
28
|
+
async withinSession(fn) {
|
|
29
|
+
const sessionId = await this.createSession();
|
|
30
|
+
const url = `http://${this.host}:${this.port}/session/${sessionId}`;
|
|
31
|
+
const result = await fn(url);
|
|
32
|
+
await this.deleteSession(sessionId);
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
async getScreenSize() {
|
|
36
|
+
return this.withinSession(async (sessionUrl) => {
|
|
37
|
+
const url = `${sessionUrl}/wda/screen`;
|
|
38
|
+
const response = await fetch(url);
|
|
39
|
+
const json = await response.json();
|
|
40
|
+
return {
|
|
41
|
+
width: json.value.screenSize.width * json.value.scale,
|
|
42
|
+
height: json.value.screenSize.height * json.value.scale,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async sendKeys(keys) {
|
|
47
|
+
await this.withinSession(async (sessionUrl) => {
|
|
48
|
+
const url = `${sessionUrl}/wda/keys`;
|
|
49
|
+
await fetch(url, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify({ value: [keys] }),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async pressButton(button) {
|
|
59
|
+
const _map = {
|
|
60
|
+
"HOME": "home",
|
|
61
|
+
"VOLUME_UP": "volumeup",
|
|
62
|
+
"VOLUME_DOWN": "volumedown",
|
|
63
|
+
};
|
|
64
|
+
if (button === "ENTER") {
|
|
65
|
+
await this.sendKeys("\n");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Type assertion to check if button is a key of _map
|
|
69
|
+
if (!(button in _map)) {
|
|
70
|
+
throw new Error(`Button "${button}" is not supported`);
|
|
71
|
+
}
|
|
72
|
+
await this.withinSession(async (sessionUrl) => {
|
|
73
|
+
const url = `${sessionUrl}/wda/pressButton`;
|
|
74
|
+
const response = await fetch(url, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
name: button,
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
return response.json();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async tap(x, y) {
|
|
87
|
+
await this.withinSession(async (sessionUrl) => {
|
|
88
|
+
const url = `${sessionUrl}/actions`;
|
|
89
|
+
await fetch(url, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: {
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
actions: [
|
|
96
|
+
{
|
|
97
|
+
type: "pointer",
|
|
98
|
+
id: "finger1",
|
|
99
|
+
parameters: { pointerType: "touch" },
|
|
100
|
+
actions: [
|
|
101
|
+
{ type: "pointerMove", duration: 0, x, y },
|
|
102
|
+
{ type: "pointerDown", button: 0 },
|
|
103
|
+
{ type: "pause", duration: 100 },
|
|
104
|
+
{ type: "pointerUp", button: 0 }
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
filterSourceElements(source) {
|
|
113
|
+
const output = [];
|
|
114
|
+
if (["TextField", "Button", "Switch"].includes(source.type)) {
|
|
115
|
+
output.push({
|
|
116
|
+
type: source.type,
|
|
117
|
+
label: source.label,
|
|
118
|
+
name: source.name,
|
|
119
|
+
rect: {
|
|
120
|
+
x0: source.rect.x,
|
|
121
|
+
y0: source.rect.y,
|
|
122
|
+
x1: source.rect.x + source.rect.width,
|
|
123
|
+
y1: source.rect.y + source.rect.height,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (source.children) {
|
|
128
|
+
for (const child of source.children) {
|
|
129
|
+
output.push(...this.filterSourceElements(child));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return output;
|
|
133
|
+
}
|
|
134
|
+
async getPageSource() {
|
|
135
|
+
const url = `http://${this.host}:${this.port}/source/?format=json`;
|
|
136
|
+
const response = await fetch(url);
|
|
137
|
+
const json = await response.json();
|
|
138
|
+
return json;
|
|
139
|
+
}
|
|
140
|
+
async getElementsOnScreen() {
|
|
141
|
+
const source = await this.getPageSource();
|
|
142
|
+
return this.filterSourceElements(source.value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
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.11",
|
|
4
4
|
"description": "Mobile MCP",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,16 +13,14 @@
|
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "tsc && chmod +x lib/index.js",
|
|
15
15
|
"lint": "eslint .",
|
|
16
|
+
"test": "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",
|
|
@@ -33,6 +31,7 @@
|
|
|
33
31
|
"@eslint/eslintrc": "^3.2.0",
|
|
34
32
|
"@eslint/js": "^9.19.0",
|
|
35
33
|
"@stylistic/eslint-plugin": "^3.0.1",
|
|
34
|
+
"@types/mocha": "^10.0.10",
|
|
36
35
|
"@types/node": "^22.13.10",
|
|
37
36
|
"@typescript-eslint/eslint-plugin": "^8.28.0",
|
|
38
37
|
"@typescript-eslint/parser": "^8.26.1",
|
|
@@ -41,6 +40,9 @@
|
|
|
41
40
|
"eslint-plugin": "^1.0.1",
|
|
42
41
|
"eslint-plugin-import": "^2.31.0",
|
|
43
42
|
"eslint-plugin-notice": "^1.0.0",
|
|
43
|
+
"husky": "^9.1.7",
|
|
44
|
+
"mocha": "^11.1.0",
|
|
45
|
+
"ts-node": "^10.9.2",
|
|
44
46
|
"typescript": "^5.8.2"
|
|
45
47
|
},
|
|
46
48
|
"main": "index.js",
|