@mobilenext/mobile-mcp 0.0.45 → 0.0.47
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/lib/android.js +10 -1
- package/lib/ios.js +9 -2
- package/lib/iphone-simulator.js +9 -2
- package/lib/mobile-device.js +6 -2
- package/lib/mobilecli.js +6 -0
- package/lib/server.js +71 -13
- package/lib/utils.js +6 -0
- package/package.json +2 -2
package/lib/android.js
CHANGED
|
@@ -137,8 +137,17 @@ class AndroidRobot {
|
|
|
137
137
|
.filter(line => line.startsWith("package:"))
|
|
138
138
|
.map(line => line.substring("package:".length));
|
|
139
139
|
}
|
|
140
|
-
async launchApp(packageName) {
|
|
140
|
+
async launchApp(packageName, locale) {
|
|
141
141
|
(0, utils_1.validatePackageName)(packageName);
|
|
142
|
+
if (locale) {
|
|
143
|
+
(0, utils_1.validateLocale)(locale);
|
|
144
|
+
try {
|
|
145
|
+
this.silentAdb("shell", "cmd", "locale", "set-app-locales", packageName, "--locales", locale);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
// set-app-locales requires Android 13+ (API 33), silently ignore on older versions
|
|
149
|
+
}
|
|
150
|
+
}
|
|
142
151
|
try {
|
|
143
152
|
this.silentAdb("shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1");
|
|
144
153
|
}
|
package/lib/ios.js
CHANGED
|
@@ -94,10 +94,17 @@ class IosRobot {
|
|
|
94
94
|
};
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
|
-
async launchApp(packageName) {
|
|
97
|
+
async launchApp(packageName, locale) {
|
|
98
98
|
(0, utils_1.validatePackageName)(packageName);
|
|
99
99
|
await this.assertTunnelRunning();
|
|
100
|
-
|
|
100
|
+
const args = ["launch", packageName];
|
|
101
|
+
if (locale) {
|
|
102
|
+
(0, utils_1.validateLocale)(locale);
|
|
103
|
+
const locales = locale.split(",").map(l => l.trim());
|
|
104
|
+
args.push("-AppleLanguages", `(${locales.join(", ")})`);
|
|
105
|
+
args.push("-AppleLocale", locales[0]);
|
|
106
|
+
}
|
|
107
|
+
await this.ios(...args);
|
|
101
108
|
}
|
|
102
109
|
async terminateApp(packageName) {
|
|
103
110
|
(0, utils_1.validatePackageName)(packageName);
|
package/lib/iphone-simulator.js
CHANGED
|
@@ -71,9 +71,16 @@ class Simctl {
|
|
|
71
71
|
await wda.openUrl(url);
|
|
72
72
|
// alternative: this.simctl("openurl", this.simulatorUuid, url);
|
|
73
73
|
}
|
|
74
|
-
async launchApp(packageName) {
|
|
74
|
+
async launchApp(packageName, locale) {
|
|
75
75
|
(0, utils_1.validatePackageName)(packageName);
|
|
76
|
-
|
|
76
|
+
const args = ["launch", this.simulatorUuid, packageName];
|
|
77
|
+
if (locale) {
|
|
78
|
+
(0, utils_1.validateLocale)(locale);
|
|
79
|
+
const locales = locale.split(",").map(l => l.trim());
|
|
80
|
+
args.push("-AppleLanguages", `(${locales.join(", ")})`);
|
|
81
|
+
args.push("-AppleLocale", locales[0]);
|
|
82
|
+
}
|
|
83
|
+
this.simctl(...args);
|
|
77
84
|
}
|
|
78
85
|
async terminateApp(packageName) {
|
|
79
86
|
(0, utils_1.validatePackageName)(packageName);
|
package/lib/mobile-device.js
CHANGED
|
@@ -80,8 +80,12 @@ class MobileDevice {
|
|
|
80
80
|
packageName: app.packageName,
|
|
81
81
|
}));
|
|
82
82
|
}
|
|
83
|
-
async launchApp(packageName) {
|
|
84
|
-
|
|
83
|
+
async launchApp(packageName, locale) {
|
|
84
|
+
const args = ["apps", "launch", packageName];
|
|
85
|
+
if (locale) {
|
|
86
|
+
args.push("--locale", locale);
|
|
87
|
+
}
|
|
88
|
+
this.runCommand(args);
|
|
85
89
|
}
|
|
86
90
|
async terminateApp(packageName) {
|
|
87
91
|
this.runCommand(["apps", "terminate", packageName]);
|
package/lib/mobilecli.js
CHANGED
|
@@ -19,6 +19,12 @@ class Mobilecli {
|
|
|
19
19
|
const path = this.getPath();
|
|
20
20
|
return (0, node_child_process_1.execFileSync)(path, args, { encoding: "utf8" }).toString().trim();
|
|
21
21
|
}
|
|
22
|
+
spawnCommand(args) {
|
|
23
|
+
const binaryPath = this.getPath();
|
|
24
|
+
return (0, node_child_process_1.spawn)(binaryPath, args, {
|
|
25
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
22
28
|
executeCommandBuffer(args) {
|
|
23
29
|
const path = this.getPath();
|
|
24
30
|
return (0, node_child_process_1.execFileSync)(path, args, {
|
package/lib/server.js
CHANGED
|
@@ -8,6 +8,7 @@ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
8
8
|
const zod_1 = require("zod");
|
|
9
9
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
10
|
const node_os_1 = __importDefault(require("node:os"));
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
12
|
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
12
13
|
const logger_1 = require("./logger");
|
|
13
14
|
const android_1 = require("./android");
|
|
@@ -110,6 +111,7 @@ const createMcpServer = () => {
|
|
|
110
111
|
}
|
|
111
112
|
};
|
|
112
113
|
const mobilecli = new mobilecli_1.Mobilecli();
|
|
114
|
+
const activeRecordings = new Map();
|
|
113
115
|
posthog("launch", {}).then();
|
|
114
116
|
const ensureMobilecliAvailable = () => {
|
|
115
117
|
try {
|
|
@@ -154,7 +156,7 @@ const createMcpServer = () => {
|
|
|
154
156
|
}
|
|
155
157
|
throw new robot_1.ActionableError(`Device "${deviceId}" not found. Use the mobile_list_available_devices tool to see available devices.`);
|
|
156
158
|
};
|
|
157
|
-
tool("mobile_list_available_devices", "List Devices", "List all available devices. This includes both physical devices and simulators
|
|
159
|
+
tool("mobile_list_available_devices", "List Devices", "List all available devices. This includes both physical mobile devices and mobile simulators and emulators. It returns both Android and iOS devices.", {}, { readOnlyHint: true }, async ({}) => {
|
|
158
160
|
// from today onward, we must have mobilecli working
|
|
159
161
|
ensureMobilecliAvailable();
|
|
160
162
|
const iosManager = new ios_1.IosManager();
|
|
@@ -241,9 +243,10 @@ const createMcpServer = () => {
|
|
|
241
243
|
tool("mobile_launch_app", "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.", {
|
|
242
244
|
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
243
245
|
packageName: zod_1.z.string().describe("The package name of the app to launch"),
|
|
244
|
-
|
|
246
|
+
locale: zod_1.z.string().optional().describe("Comma-separated BCP 47 locale tags to launch the app with (e.g., fr-FR,en-GB)"),
|
|
247
|
+
}, { destructiveHint: true }, async ({ device, packageName, locale }) => {
|
|
245
248
|
const robot = getRobotFromDevice(device);
|
|
246
|
-
await robot.launchApp(packageName);
|
|
249
|
+
await robot.launchApp(packageName, locale);
|
|
247
250
|
return `Launched app ${packageName}`;
|
|
248
251
|
});
|
|
249
252
|
tool("mobile_terminate_app", "Terminate App", "Stop and terminate an app on mobile device", {
|
|
@@ -279,8 +282,8 @@ const createMcpServer = () => {
|
|
|
279
282
|
});
|
|
280
283
|
tool("mobile_click_on_screen_at_coordinates", "Click Screen", "Click on the screen at given x,y coordinates. If clicking on an element, use the list_elements_on_screen tool to find the coordinates.", {
|
|
281
284
|
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
282
|
-
x: zod_1.z.number().describe("The x coordinate to click on the screen, in pixels"),
|
|
283
|
-
y: zod_1.z.number().describe("The y coordinate to click on the screen, in pixels"),
|
|
285
|
+
x: zod_1.z.coerce.number().describe("The x coordinate to click on the screen, in pixels"),
|
|
286
|
+
y: zod_1.z.coerce.number().describe("The y coordinate to click on the screen, in pixels"),
|
|
284
287
|
}, { destructiveHint: true }, async ({ device, x, y }) => {
|
|
285
288
|
const robot = getRobotFromDevice(device);
|
|
286
289
|
await robot.tap(x, y);
|
|
@@ -288,8 +291,8 @@ const createMcpServer = () => {
|
|
|
288
291
|
});
|
|
289
292
|
tool("mobile_double_tap_on_screen", "Double Tap Screen", "Double-tap on the screen at given x,y coordinates.", {
|
|
290
293
|
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
291
|
-
x: zod_1.z.number().describe("The x coordinate to double-tap, in pixels"),
|
|
292
|
-
y: zod_1.z.number().describe("The y coordinate to double-tap, in pixels"),
|
|
294
|
+
x: zod_1.z.coerce.number().describe("The x coordinate to double-tap, in pixels"),
|
|
295
|
+
y: zod_1.z.coerce.number().describe("The y coordinate to double-tap, in pixels"),
|
|
293
296
|
}, { destructiveHint: true }, async ({ device, x, y }) => {
|
|
294
297
|
const robot = getRobotFromDevice(device);
|
|
295
298
|
await robot.doubleTap(x, y);
|
|
@@ -297,9 +300,9 @@ const createMcpServer = () => {
|
|
|
297
300
|
});
|
|
298
301
|
tool("mobile_long_press_on_screen_at_coordinates", "Long Press Screen", "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.", {
|
|
299
302
|
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
300
|
-
x: zod_1.z.number().describe("The x coordinate to long press on the screen, in pixels"),
|
|
301
|
-
y: zod_1.z.number().describe("The y coordinate to long press on the screen, in pixels"),
|
|
302
|
-
duration: zod_1.z.number().min(1).max(10000).optional().describe("Duration of the long press in milliseconds. Defaults to 500ms."),
|
|
303
|
+
x: zod_1.z.coerce.number().describe("The x coordinate to long press on the screen, in pixels"),
|
|
304
|
+
y: zod_1.z.coerce.number().describe("The y coordinate to long press on the screen, in pixels"),
|
|
305
|
+
duration: zod_1.z.coerce.number().min(1).max(10000).optional().describe("Duration of the long press in milliseconds. Defaults to 500ms."),
|
|
303
306
|
}, { destructiveHint: true }, async ({ device, x, y, duration }) => {
|
|
304
307
|
const robot = getRobotFromDevice(device);
|
|
305
308
|
const pressDuration = duration ?? 500;
|
|
@@ -352,9 +355,9 @@ const createMcpServer = () => {
|
|
|
352
355
|
tool("mobile_swipe_on_screen", "Swipe Screen", "Swipe on the screen", {
|
|
353
356
|
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
354
357
|
direction: zod_1.z.enum(["up", "down", "left", "right"]).describe("The direction to swipe"),
|
|
355
|
-
x: zod_1.z.number().optional().describe("The x coordinate to start the swipe from, in pixels. If not provided, uses center of screen"),
|
|
356
|
-
y: zod_1.z.number().optional().describe("The y coordinate to start the swipe from, in pixels. If not provided, uses center of screen"),
|
|
357
|
-
distance: zod_1.z.number().optional().describe("The distance to swipe in pixels. Defaults to 400 pixels for iOS or 30% of screen dimension for Android"),
|
|
358
|
+
x: zod_1.z.coerce.number().optional().describe("The x coordinate to start the swipe from, in pixels. If not provided, uses center of screen"),
|
|
359
|
+
y: zod_1.z.coerce.number().optional().describe("The y coordinate to start the swipe from, in pixels. If not provided, uses center of screen"),
|
|
360
|
+
distance: zod_1.z.coerce.number().optional().describe("The distance to swipe in pixels. Defaults to 400 pixels for iOS or 30% of screen dimension for Android"),
|
|
358
361
|
}, { destructiveHint: true }, async ({ device, direction, x, y, distance }) => {
|
|
359
362
|
const robot = getRobotFromDevice(device);
|
|
360
363
|
if (x !== undefined && y !== undefined) {
|
|
@@ -458,6 +461,61 @@ const createMcpServer = () => {
|
|
|
458
461
|
const orientation = await robot.getOrientation();
|
|
459
462
|
return `Current device orientation is ${orientation}`;
|
|
460
463
|
});
|
|
464
|
+
tool("mobile_start_screen_recording", "Start Screen Recording", "Start recording the screen of a mobile device. The recording runs in the background until stopped with mobile_stop_screen_recording. Returns the path where the recording will be saved.", {
|
|
465
|
+
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
466
|
+
output: zod_1.z.string().optional().describe("The file path to save the recording to. If not provided, a temporary path will be used."),
|
|
467
|
+
timeLimit: zod_1.z.coerce.number().optional().describe("Maximum recording duration in seconds. The recording will stop automatically after this time."),
|
|
468
|
+
}, { destructiveHint: true }, async ({ device, output, timeLimit }) => {
|
|
469
|
+
getRobotFromDevice(device);
|
|
470
|
+
if (activeRecordings.has(device)) {
|
|
471
|
+
throw new robot_1.ActionableError(`Device "${device}" is already being recorded. Stop the current recording first with mobile_stop_screen_recording.`);
|
|
472
|
+
}
|
|
473
|
+
const outputPath = output || node_path_1.default.join(node_os_1.default.tmpdir(), `screen-recording-${Date.now()}.mp4`);
|
|
474
|
+
const args = ["screenrecord", "--device", device, "--output", outputPath, "--silent"];
|
|
475
|
+
if (timeLimit !== undefined) {
|
|
476
|
+
args.push("--time-limit", String(timeLimit));
|
|
477
|
+
}
|
|
478
|
+
const child = mobilecli.spawnCommand(args);
|
|
479
|
+
const cleanup = () => {
|
|
480
|
+
activeRecordings.delete(device);
|
|
481
|
+
};
|
|
482
|
+
child.on("error", cleanup);
|
|
483
|
+
child.on("exit", cleanup);
|
|
484
|
+
activeRecordings.set(device, {
|
|
485
|
+
process: child,
|
|
486
|
+
outputPath,
|
|
487
|
+
startedAt: Date.now(),
|
|
488
|
+
});
|
|
489
|
+
return `Screen recording started. Output will be saved to: ${outputPath}`;
|
|
490
|
+
});
|
|
491
|
+
tool("mobile_stop_screen_recording", "Stop Screen Recording", "Stop an active screen recording on a mobile device. Returns the file path, size, and approximate duration of the recording.", {
|
|
492
|
+
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
493
|
+
}, { destructiveHint: true }, async ({ device }) => {
|
|
494
|
+
const recording = activeRecordings.get(device);
|
|
495
|
+
if (!recording) {
|
|
496
|
+
throw new robot_1.ActionableError(`No active recording found for device "${device}". Start a recording first with mobile_start_screen_recording.`);
|
|
497
|
+
}
|
|
498
|
+
const { process: child, outputPath, startedAt } = recording;
|
|
499
|
+
activeRecordings.delete(device);
|
|
500
|
+
child.kill("SIGINT");
|
|
501
|
+
await new Promise(resolve => {
|
|
502
|
+
const timeout = setTimeout(() => {
|
|
503
|
+
child.kill("SIGKILL");
|
|
504
|
+
resolve();
|
|
505
|
+
}, 5 * 60 * 1000);
|
|
506
|
+
child.on("close", () => {
|
|
507
|
+
clearTimeout(timeout);
|
|
508
|
+
resolve();
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
const durationSeconds = Math.round((Date.now() - startedAt) / 1000);
|
|
512
|
+
if (!node_fs_1.default.existsSync(outputPath)) {
|
|
513
|
+
return `Recording stopped after ~${durationSeconds}s but the output file was not found at: ${outputPath}`;
|
|
514
|
+
}
|
|
515
|
+
const stats = node_fs_1.default.statSync(outputPath);
|
|
516
|
+
const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2);
|
|
517
|
+
return `Recording stopped. File: ${outputPath} (${fileSizeMB} MB, ~${durationSeconds}s)`;
|
|
518
|
+
});
|
|
461
519
|
return server;
|
|
462
520
|
};
|
|
463
521
|
exports.createMcpServer = createMcpServer;
|
package/lib/utils.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.validatePackageName = validatePackageName;
|
|
4
|
+
exports.validateLocale = validateLocale;
|
|
4
5
|
const robot_1 = require("./robot");
|
|
5
6
|
function validatePackageName(packageName) {
|
|
6
7
|
if (!/^[a-zA-Z0-9._]+$/.test(packageName)) {
|
|
7
8
|
throw new robot_1.ActionableError(`Invalid package name: "${packageName}"`);
|
|
8
9
|
}
|
|
9
10
|
}
|
|
11
|
+
function validateLocale(locale) {
|
|
12
|
+
if (!/^[a-zA-Z0-9,\- ]+$/.test(locale)) {
|
|
13
|
+
throw new robot_1.ActionableError(`Invalid locale: "${locale}"`);
|
|
14
|
+
}
|
|
15
|
+
}
|
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.47",
|
|
5
5
|
"description": "Mobile MCP",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"zod-to-json-schema": "3.25.0"
|
|
35
35
|
},
|
|
36
36
|
"optionalDependencies": {
|
|
37
|
-
"@mobilenext/mobilecli": "0.1.
|
|
37
|
+
"@mobilenext/mobilecli": "0.1.60"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@eslint/eslintrc": "^3.2.0",
|