@mobilenext/mobile-mcp 0.0.31-beta → 0.0.32
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 +60 -9
- package/lib/android.js +76 -16
- package/lib/ios.js +28 -0
- package/lib/iphone-simulator.js +88 -0
- package/lib/server.js +48 -33
- package/lib/webdriver-agent.js +30 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@ More details in our [wiki page](https://github.com/mobile-next/mobile-mcp/wiki)
|
|
|
83
83
|
|
|
84
84
|
## Installation and configuration
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
**Standard config** works in most of the tools:
|
|
87
87
|
|
|
88
88
|
```json
|
|
89
89
|
{
|
|
@@ -94,23 +94,74 @@ Setup our MCP with Cline, Cursor, Claude, VS Code, Github Copilot:
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
-
|
|
98
97
|
```
|
|
99
|
-
|
|
98
|
+
|
|
99
|
+
<details>
|
|
100
|
+
<summary>Cline</summary>
|
|
101
|
+
|
|
102
|
+
To setup Cline, just add the json above to your MCP settings file.
|
|
103
|
+
|
|
100
104
|
[More in our wiki](https://github.com/mobile-next/mobile-mcp/wiki/Cline)
|
|
101
105
|
|
|
102
|
-
|
|
106
|
+
</details>
|
|
103
107
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
```
|
|
108
|
+
<details>
|
|
109
|
+
<summary>Claude Code</summary>
|
|
107
110
|
|
|
108
|
-
|
|
111
|
+
Use the Claude Code CLI to add the Mobile MCP server:
|
|
109
112
|
|
|
113
|
+
```bash
|
|
114
|
+
claude mcp add mobile-mcp -- npx -y @mobilenext/mobile-mcp@latest
|
|
110
115
|
```
|
|
111
|
-
|
|
116
|
+
|
|
117
|
+
</details>
|
|
118
|
+
|
|
119
|
+
<details>
|
|
120
|
+
<summary>Cursor</summary>
|
|
121
|
+
|
|
122
|
+
#### Click the button to install:
|
|
123
|
+
|
|
124
|
+
[<img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Install in Cursor">](https://cursor.com/en/install-mcp?name=Mobile%20MCP&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBtb2JpbGVuZXh0L21vYmlsZS1tY3BAbGF0ZXN0Il19)
|
|
125
|
+
|
|
126
|
+
#### Or install manually:
|
|
127
|
+
|
|
128
|
+
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx -y @mobilenext/mobile-mcp@latest`. You can also verify config or add command like arguments via clicking `Edit`.
|
|
129
|
+
|
|
130
|
+
</details>
|
|
131
|
+
|
|
132
|
+
<details>
|
|
133
|
+
<summary>Gemini CLI</summary>
|
|
134
|
+
|
|
135
|
+
Use the Gemini CLI to add the Mobile MCP server:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
gemini mcp add mobile-mcp npx -y @mobilenext/mobile-mcp@latest
|
|
112
139
|
```
|
|
113
140
|
|
|
141
|
+
</details>
|
|
142
|
+
|
|
143
|
+
<details>
|
|
144
|
+
<summary>Goose</summary>
|
|
145
|
+
|
|
146
|
+
#### Click the button to install:
|
|
147
|
+
|
|
148
|
+
[](https://block.github.io/goose/extension?cmd=npx&arg=-y&arg=%40mobilenext%2Fmobile-mcp%40latest&id=mobile-mcp&name=Mobile%20MCP&description=Mobile%20automation%20and%20development%20for%20iOS%2C%20Android%2C%20simulators%2C%20emulators%2C%20and%20real%20devices)
|
|
149
|
+
|
|
150
|
+
#### Or install manually:
|
|
151
|
+
|
|
152
|
+
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx -y @mobilenext/mobile-mcp@latest`. Click "Add Extension".
|
|
153
|
+
|
|
154
|
+
</details>
|
|
155
|
+
|
|
156
|
+
<details>
|
|
157
|
+
<summary>Qodo Gen</summary>
|
|
158
|
+
|
|
159
|
+
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the standard config above.
|
|
160
|
+
|
|
161
|
+
Click <code>Save</code>.
|
|
162
|
+
|
|
163
|
+
</details>
|
|
164
|
+
|
|
114
165
|
[Read more in our wiki](https://github.com/mobile-next/mobile-mcp/wiki)! 🚀
|
|
115
166
|
|
|
116
167
|
|
package/lib/android.js
CHANGED
|
@@ -120,7 +120,7 @@ class AndroidRobot {
|
|
|
120
120
|
.map(line => line.substring("package:".length));
|
|
121
121
|
}
|
|
122
122
|
async launchApp(packageName) {
|
|
123
|
-
this.adb("shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1");
|
|
123
|
+
this.adb("shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1", "1>/dev/null", "2>/dev/null");
|
|
124
124
|
}
|
|
125
125
|
async listRunningProcesses() {
|
|
126
126
|
return this.adb("shell", "ps", "-e")
|
|
@@ -202,23 +202,51 @@ class AndroidRobot {
|
|
|
202
202
|
.length;
|
|
203
203
|
}
|
|
204
204
|
getFirstDisplayId() {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
const
|
|
217
|
-
if (
|
|
218
|
-
|
|
205
|
+
try {
|
|
206
|
+
// Try using cmd display get-displays (Android 11+)
|
|
207
|
+
const displays = this.adb("shell", "cmd", "display", "get-displays")
|
|
208
|
+
.toString()
|
|
209
|
+
.split("\n")
|
|
210
|
+
.filter(s => s.startsWith("Display id "))
|
|
211
|
+
// filter for state ON even though get-displays only returns turned on displays
|
|
212
|
+
.filter(s => s.indexOf(", state ON,") >= 0)
|
|
213
|
+
// another paranoia check
|
|
214
|
+
.filter(s => s.indexOf(", uniqueId ") >= 0);
|
|
215
|
+
if (displays.length > 0) {
|
|
216
|
+
const m = displays[0].match(/uniqueId \"([^\"]+)\"/);
|
|
217
|
+
if (m !== null) {
|
|
218
|
+
let displayId = m[1];
|
|
219
|
+
if (displayId.startsWith("local:")) {
|
|
220
|
+
displayId = displayId.substring("local:".length);
|
|
221
|
+
}
|
|
222
|
+
return displayId;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
// cmd display get-displays not available on this device
|
|
228
|
+
}
|
|
229
|
+
// fallback: parse dumpsys display for display info (compatible with older Android versions)
|
|
230
|
+
try {
|
|
231
|
+
const dumpsys = this.adb("shell", "dumpsys", "display")
|
|
232
|
+
.toString();
|
|
233
|
+
// look for DisplayViewport entries with isActive=true and type=INTERNAL
|
|
234
|
+
const viewportMatch = dumpsys.match(/DisplayViewport\{type=INTERNAL[^}]*isActive=true[^}]*uniqueId='([^']+)'/);
|
|
235
|
+
if (viewportMatch) {
|
|
236
|
+
let uniqueId = viewportMatch[1];
|
|
237
|
+
if (uniqueId.startsWith("local:")) {
|
|
238
|
+
uniqueId = uniqueId.substring("local:".length);
|
|
219
239
|
}
|
|
220
|
-
return
|
|
240
|
+
return uniqueId;
|
|
221
241
|
}
|
|
242
|
+
// fallback: look for active display with state ON
|
|
243
|
+
const displayStateMatch = dumpsys.match(/Display Id=(\d+)[\s\S]*?Display State=ON/);
|
|
244
|
+
if (displayStateMatch) {
|
|
245
|
+
return displayStateMatch[1];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
// dumpsys display also failed
|
|
222
250
|
}
|
|
223
251
|
return null;
|
|
224
252
|
}
|
|
@@ -229,6 +257,11 @@ class AndroidRobot {
|
|
|
229
257
|
}
|
|
230
258
|
// find the first display that is turned on, and capture that one
|
|
231
259
|
const displayId = this.getFirstDisplayId();
|
|
260
|
+
if (displayId === null) {
|
|
261
|
+
// no idea why, but we have displayCount >= 2, yet we failed to parse
|
|
262
|
+
// let's go with screencap's defaults and hope for the best
|
|
263
|
+
return this.adb("exec-out", "screencap", "-p");
|
|
264
|
+
}
|
|
232
265
|
return this.adb("exec-out", "screencap", "-p", "-d", `${displayId}`);
|
|
233
266
|
}
|
|
234
267
|
collectElements(node) {
|
|
@@ -273,6 +306,28 @@ class AndroidRobot {
|
|
|
273
306
|
async terminateApp(packageName) {
|
|
274
307
|
this.adb("shell", "am", "force-stop", packageName);
|
|
275
308
|
}
|
|
309
|
+
async installApp(path) {
|
|
310
|
+
try {
|
|
311
|
+
this.adb("install", "-r", path);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
const stdout = error.stdout ? error.stdout.toString() : "";
|
|
315
|
+
const stderr = error.stderr ? error.stderr.toString() : "";
|
|
316
|
+
const output = (stdout + stderr).trim();
|
|
317
|
+
throw new robot_1.ActionableError(output || error.message);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async uninstallApp(bundleId) {
|
|
321
|
+
try {
|
|
322
|
+
this.adb("uninstall", bundleId);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
const stdout = error.stdout ? error.stdout.toString() : "";
|
|
326
|
+
const stderr = error.stderr ? error.stderr.toString() : "";
|
|
327
|
+
const output = (stdout + stderr).trim();
|
|
328
|
+
throw new robot_1.ActionableError(output || error.message);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
276
331
|
async openUrl(url) {
|
|
277
332
|
this.adb("shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", url);
|
|
278
333
|
}
|
|
@@ -326,6 +381,11 @@ class AndroidRobot {
|
|
|
326
381
|
// a long press is a swipe with no movement and a long duration
|
|
327
382
|
this.adb("shell", "input", "swipe", `${x}`, `${y}`, `${x}`, `${y}`, "500");
|
|
328
383
|
}
|
|
384
|
+
async doubleTap(x, y) {
|
|
385
|
+
await this.tap(x, y);
|
|
386
|
+
await new Promise(r => setTimeout(r, 100)); // short delay
|
|
387
|
+
await this.tap(x, y);
|
|
388
|
+
}
|
|
329
389
|
async setOrientation(orientation) {
|
|
330
390
|
const value = orientation === "portrait" ? 0 : 1;
|
|
331
391
|
// disable auto-rotation prior to setting the orientation
|
package/lib/ios.js
CHANGED
|
@@ -101,6 +101,30 @@ class IosRobot {
|
|
|
101
101
|
await this.assertTunnelRunning();
|
|
102
102
|
await this.ios("kill", packageName);
|
|
103
103
|
}
|
|
104
|
+
async installApp(path) {
|
|
105
|
+
await this.assertTunnelRunning();
|
|
106
|
+
try {
|
|
107
|
+
await this.ios("install", "--path", path);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
const stdout = error.stdout ? error.stdout.toString() : "";
|
|
111
|
+
const stderr = error.stderr ? error.stderr.toString() : "";
|
|
112
|
+
const output = (stdout + stderr).trim();
|
|
113
|
+
throw new robot_1.ActionableError(output || error.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async uninstallApp(bundleId) {
|
|
117
|
+
await this.assertTunnelRunning();
|
|
118
|
+
try {
|
|
119
|
+
await this.ios("uninstall", "--bundleid", bundleId);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const stdout = error.stdout ? error.stdout.toString() : "";
|
|
123
|
+
const stderr = error.stderr ? error.stderr.toString() : "";
|
|
124
|
+
const output = (stdout + stderr).trim();
|
|
125
|
+
throw new robot_1.ActionableError(output || error.message);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
104
128
|
async openUrl(url) {
|
|
105
129
|
const wda = await this.wda();
|
|
106
130
|
await wda.openUrl(url);
|
|
@@ -117,6 +141,10 @@ class IosRobot {
|
|
|
117
141
|
const wda = await this.wda();
|
|
118
142
|
await wda.tap(x, y);
|
|
119
143
|
}
|
|
144
|
+
async doubleTap(x, y) {
|
|
145
|
+
const wda = await this.wda();
|
|
146
|
+
await wda.doubleTap(x, y);
|
|
147
|
+
}
|
|
120
148
|
async longPress(x, y) {
|
|
121
149
|
const wda = await this.wda();
|
|
122
150
|
await wda.longPress(x, y);
|
package/lib/iphone-simulator.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SimctlManager = exports.Simctl = void 0;
|
|
4
4
|
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_os_1 = require("node:os");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
5
8
|
const logger_1 = require("./logger");
|
|
6
9
|
const webdriver_agent_1 = require("./webdriver-agent");
|
|
7
10
|
const robot_1 = require("./robot");
|
|
@@ -73,6 +76,87 @@ class Simctl {
|
|
|
73
76
|
async terminateApp(packageName) {
|
|
74
77
|
this.simctl("terminate", this.simulatorUuid, packageName);
|
|
75
78
|
}
|
|
79
|
+
findAppBundle(dir) {
|
|
80
|
+
const entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (entry.isDirectory() && entry.name.endsWith(".app")) {
|
|
83
|
+
return (0, node_path_1.join)(dir, entry.name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
validateZipPaths(zipPath) {
|
|
89
|
+
const output = (0, node_child_process_1.execFileSync)("/usr/bin/zipinfo", ["-1", zipPath], {
|
|
90
|
+
timeout: TIMEOUT,
|
|
91
|
+
maxBuffer: MAX_BUFFER_SIZE,
|
|
92
|
+
}).toString();
|
|
93
|
+
const invalidPath = output
|
|
94
|
+
.split("\n")
|
|
95
|
+
.map(s => s.trim())
|
|
96
|
+
.filter(s => s)
|
|
97
|
+
.find(s => s.startsWith("/") || s.includes(".."));
|
|
98
|
+
if (invalidPath) {
|
|
99
|
+
throw new robot_1.ActionableError(`Security violation: File path '${invalidPath}' contains invalid characters`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async installApp(path) {
|
|
103
|
+
let tempDir = null;
|
|
104
|
+
let installPath = path;
|
|
105
|
+
try {
|
|
106
|
+
// zip files need to be extracted prior to installation
|
|
107
|
+
if ((0, node_path_1.extname)(path).toLowerCase() === ".zip") {
|
|
108
|
+
(0, logger_1.trace)(`Detected .zip file, validating contents`);
|
|
109
|
+
// before extracting, let's make sure there's no zip-slip bombs here
|
|
110
|
+
this.validateZipPaths(path);
|
|
111
|
+
tempDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "ios-app-"));
|
|
112
|
+
try {
|
|
113
|
+
(0, node_child_process_1.execFileSync)("unzip", ["-q", path, "-d", tempDir], {
|
|
114
|
+
timeout: TIMEOUT,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
throw new robot_1.ActionableError(`Failed to unzip file: ${error.message}`);
|
|
119
|
+
}
|
|
120
|
+
const appBundle = this.findAppBundle(tempDir);
|
|
121
|
+
if (!appBundle) {
|
|
122
|
+
throw new robot_1.ActionableError("No .app bundle found in the .zip file, please visit wiki at https://github.com/mobile-next/mobile-mcp/wiki for assistance.");
|
|
123
|
+
}
|
|
124
|
+
installPath = appBundle;
|
|
125
|
+
(0, logger_1.trace)(`Found .app bundle at: ${(0, node_path_1.basename)(appBundle)}`);
|
|
126
|
+
}
|
|
127
|
+
// continue with installation
|
|
128
|
+
this.simctl("install", this.simulatorUuid, installPath);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
const stdout = error.stdout ? error.stdout.toString() : "";
|
|
132
|
+
const stderr = error.stderr ? error.stderr.toString() : "";
|
|
133
|
+
const output = (stdout + stderr).trim();
|
|
134
|
+
throw new robot_1.ActionableError(output || error.message);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
// Clean up temporary directory if it was created
|
|
138
|
+
if (tempDir) {
|
|
139
|
+
try {
|
|
140
|
+
(0, logger_1.trace)(`Cleaning up temporary directory`);
|
|
141
|
+
(0, node_fs_1.rmSync)(tempDir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
catch (cleanupError) {
|
|
144
|
+
(0, logger_1.trace)(`Warning: Failed to cleanup temporary directory: ${cleanupError}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async uninstallApp(bundleId) {
|
|
150
|
+
try {
|
|
151
|
+
this.simctl("uninstall", this.simulatorUuid, bundleId);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
const stdout = error.stdout ? error.stdout.toString() : "";
|
|
155
|
+
const stderr = error.stderr ? error.stderr.toString() : "";
|
|
156
|
+
const output = (stdout + stderr).trim();
|
|
157
|
+
throw new robot_1.ActionableError(output || error.message);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
76
160
|
async listApps() {
|
|
77
161
|
const text = this.simctl("listapps", this.simulatorUuid).toString();
|
|
78
162
|
const result = (0, node_child_process_1.execFileSync)("plutil", ["-convert", "json", "-o", "-", "-r", "-"], {
|
|
@@ -104,6 +188,10 @@ class Simctl {
|
|
|
104
188
|
const wda = await this.wda();
|
|
105
189
|
return wda.tap(x, y);
|
|
106
190
|
}
|
|
191
|
+
async doubleTap(x, y) {
|
|
192
|
+
const wda = await this.wda();
|
|
193
|
+
await wda.doubleTap(x, y);
|
|
194
|
+
}
|
|
107
195
|
async longPress(x, y) {
|
|
108
196
|
const wda = await this.wda();
|
|
109
197
|
return wda.longPress(x, y);
|
package/lib/server.js
CHANGED
|
@@ -34,8 +34,16 @@ const createMcpServer = () => {
|
|
|
34
34
|
});
|
|
35
35
|
// an empty object to satisfy windsurf
|
|
36
36
|
const noParams = zod_1.z.object({});
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const getClientName = () => {
|
|
38
|
+
try {
|
|
39
|
+
const clientInfo = server.server.getClientVersion();
|
|
40
|
+
const clientName = clientInfo?.name || "unknown";
|
|
41
|
+
return clientName;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
return "unknown";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
39
47
|
const tool = (name, description, paramsSchema, cb) => {
|
|
40
48
|
const wrappedCb = async (args) => {
|
|
41
49
|
try {
|
|
@@ -76,8 +84,11 @@ const createMcpServer = () => {
|
|
|
76
84
|
Product: "mobile-mcp",
|
|
77
85
|
Version: (0, exports.getAgentVersion)(),
|
|
78
86
|
NodeVersion: process.version,
|
|
79
|
-
AgentName: clientName,
|
|
80
87
|
};
|
|
88
|
+
const clientName = getClientName();
|
|
89
|
+
if (clientName !== "unknown") {
|
|
90
|
+
systemProps.AgentName = clientName;
|
|
91
|
+
}
|
|
81
92
|
await fetch(url, {
|
|
82
93
|
method: "POST",
|
|
83
94
|
headers: {
|
|
@@ -98,20 +109,21 @@ const createMcpServer = () => {
|
|
|
98
109
|
// ignore
|
|
99
110
|
}
|
|
100
111
|
};
|
|
101
|
-
const
|
|
112
|
+
const getMobilecliVersion = () => {
|
|
102
113
|
try {
|
|
103
114
|
const path = (0, mobilecli_1.getMobilecliPath)();
|
|
104
115
|
const output = (0, node_child_process_1.execFileSync)(path, ["--version"], { encoding: "utf8" }).toString().trim();
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
if (output.startsWith("mobilecli version ")) {
|
|
117
|
+
return output.substring("mobilecli version ".length);
|
|
118
|
+
}
|
|
119
|
+
return "failed";
|
|
109
120
|
}
|
|
110
121
|
catch (error) {
|
|
111
|
-
|
|
122
|
+
return "failed " + error.message;
|
|
112
123
|
}
|
|
113
124
|
};
|
|
114
|
-
|
|
125
|
+
const mobilecliVersion = getMobilecliVersion();
|
|
126
|
+
posthog("launch", { "MobilecliVersion": mobilecliVersion }).then();
|
|
115
127
|
const simulatorManager = new iphone_simulator_1.SimctlManager();
|
|
116
128
|
const getRobotFromDevice = (device) => {
|
|
117
129
|
const iosManager = new ios_1.IosManager();
|
|
@@ -150,7 +162,7 @@ const createMcpServer = () => {
|
|
|
150
162
|
const androidMobileDevices = androidDevices.filter(d => d.deviceType === "mobile").map(d => d.deviceId);
|
|
151
163
|
const resp = ["Found these devices:"];
|
|
152
164
|
if (simulatorNames.length > 0) {
|
|
153
|
-
resp.push(`iOS simulators: [${simulatorNames.join("
|
|
165
|
+
resp.push(`iOS simulators: [${simulatorNames.join(",")}]`);
|
|
154
166
|
}
|
|
155
167
|
if (iosDevices.length > 0) {
|
|
156
168
|
resp.push(`iOS devices: [${iosDeviceNames.join(",")}]`);
|
|
@@ -186,6 +198,22 @@ const createMcpServer = () => {
|
|
|
186
198
|
await robot.terminateApp(packageName);
|
|
187
199
|
return `Terminated app ${packageName}`;
|
|
188
200
|
});
|
|
201
|
+
tool("mobile_install_app", "Install an app on mobile device", {
|
|
202
|
+
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
203
|
+
path: zod_1.z.string().describe("The path to the app file to install. For iOS simulators, provide a .zip file or a .app directory. For Android provide an .apk file. For iOS real devices provide an .ipa file"),
|
|
204
|
+
}, async ({ device, path }) => {
|
|
205
|
+
const robot = getRobotFromDevice(device);
|
|
206
|
+
await robot.installApp(path);
|
|
207
|
+
return `Installed app from ${path}`;
|
|
208
|
+
});
|
|
209
|
+
tool("mobile_uninstall_app", "Uninstall an app from mobile device", {
|
|
210
|
+
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
211
|
+
bundle_id: zod_1.z.string().describe("Bundle identifier (iOS) or package name (Android) of the app to be uninstalled"),
|
|
212
|
+
}, async ({ device, bundle_id }) => {
|
|
213
|
+
const robot = getRobotFromDevice(device);
|
|
214
|
+
await robot.uninstallApp(bundle_id);
|
|
215
|
+
return `Uninstalled app ${bundle_id}`;
|
|
216
|
+
});
|
|
189
217
|
tool("mobile_get_screen_size", "Get the screen size of the mobile device in pixels", {
|
|
190
218
|
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.")
|
|
191
219
|
}, async ({ device }) => {
|
|
@@ -202,6 +230,15 @@ const createMcpServer = () => {
|
|
|
202
230
|
await robot.tap(x, y);
|
|
203
231
|
return `Clicked on screen at coordinates: ${x}, ${y}`;
|
|
204
232
|
});
|
|
233
|
+
tool("mobile_double_tap_on_screen", "Double-tap on the screen at given x,y coordinates.", {
|
|
234
|
+
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
235
|
+
x: zod_1.z.number().describe("The x coordinate to double-tap, in pixels"),
|
|
236
|
+
y: zod_1.z.number().describe("The y coordinate to double-tap, in pixels"),
|
|
237
|
+
}, async ({ device, x, y }) => {
|
|
238
|
+
const robot = getRobotFromDevice(device);
|
|
239
|
+
await robot.doubleTap(x, y);
|
|
240
|
+
return `Double-tapped on screen at coordinates: ${x}, ${y}`;
|
|
241
|
+
});
|
|
205
242
|
tool("mobile_long_press_on_screen_at_coordinates", "Long press on the screen at given x,y coordinates. If long pressing on an element, use the list_elements_on_screen tool to find the coordinates.", {
|
|
206
243
|
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
207
244
|
x: zod_1.z.number().describe("The x coordinate to long press on the screen, in pixels"),
|
|
@@ -349,28 +386,6 @@ const createMcpServer = () => {
|
|
|
349
386
|
const orientation = await robot.getOrientation();
|
|
350
387
|
return `Current device orientation is ${orientation}`;
|
|
351
388
|
});
|
|
352
|
-
tool("mobile_test_mobilecli", "Test mobilecli integration and report status", {
|
|
353
|
-
noParams,
|
|
354
|
-
}, async () => {
|
|
355
|
-
const path = (0, mobilecli_1.getMobilecliPath)();
|
|
356
|
-
const output = (0, node_child_process_1.execFileSync)(path, ["devices"], { encoding: "utf8" }).toString().trim();
|
|
357
|
-
return `Here are the available devices: ${output}`;
|
|
358
|
-
});
|
|
359
|
-
// async report mobilecli version
|
|
360
|
-
reportMobilecliVersion().then();
|
|
361
|
-
const hook = server.connect;
|
|
362
|
-
server.connect = (transport) => {
|
|
363
|
-
transport.onmessage = (message) => {
|
|
364
|
-
if ("method" in message) {
|
|
365
|
-
const request = message;
|
|
366
|
-
if (request.method === "initialize") {
|
|
367
|
-
const initialize = request;
|
|
368
|
-
clientName = initialize.params.clientInfo.name || "unknown";
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
};
|
|
372
|
-
return hook.apply(server, [transport]);
|
|
373
|
-
};
|
|
374
389
|
return server;
|
|
375
390
|
};
|
|
376
391
|
exports.createMcpServer = createMcpServer;
|
package/lib/webdriver-agent.js
CHANGED
|
@@ -142,6 +142,36 @@ class WebDriverAgent {
|
|
|
142
142
|
});
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
+
async doubleTap(x, y) {
|
|
146
|
+
await this.withinSession(async (sessionUrl) => {
|
|
147
|
+
const url = `${sessionUrl}/actions`;
|
|
148
|
+
await fetch(url, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: {
|
|
151
|
+
"Content-Type": "application/json",
|
|
152
|
+
},
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
actions: [
|
|
155
|
+
{
|
|
156
|
+
type: "pointer",
|
|
157
|
+
id: "finger1",
|
|
158
|
+
parameters: { pointerType: "touch" },
|
|
159
|
+
actions: [
|
|
160
|
+
{ type: "pointerMove", duration: 0, x, y },
|
|
161
|
+
{ type: "pointerDown", button: 0 },
|
|
162
|
+
{ type: "pause", duration: 50 },
|
|
163
|
+
{ type: "pointerUp", button: 0 },
|
|
164
|
+
{ type: "pause", duration: 100 },
|
|
165
|
+
{ type: "pointerDown", button: 0 },
|
|
166
|
+
{ type: "pause", duration: 50 },
|
|
167
|
+
{ type: "pointerUp", button: 0 }
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
145
175
|
async longPress(x, y) {
|
|
146
176
|
await this.withinSession(async (sessionUrl) => {
|
|
147
177
|
const url = `${sessionUrl}/actions`;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mobilenext/mobile-mcp",
|
|
3
3
|
"mcpName": "io.github.mobile-next/mobile-mcp",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.32",
|
|
5
5
|
"description": "Mobile MCP",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -28,8 +28,10 @@
|
|
|
28
28
|
"commander": "14.0.0",
|
|
29
29
|
"express": "5.1.0",
|
|
30
30
|
"fast-xml-parser": "5.2.5",
|
|
31
|
-
"zod-to-json-schema": "3.24.6"
|
|
32
|
-
|
|
31
|
+
"zod-to-json-schema": "3.24.6"
|
|
32
|
+
},
|
|
33
|
+
"optionalDependencies": {
|
|
34
|
+
"@mobilenext/mobilecli": "0.0.28"
|
|
33
35
|
},
|
|
34
36
|
"devDependencies": {
|
|
35
37
|
"@eslint/eslintrc": "^3.2.0",
|