@mobilenext/mobile-mcp 0.0.48 → 0.0.50
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 +0 -9
- package/lib/server.js +15 -2
- package/lib/utils.js +65 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,24 +7,15 @@ This server allows Agents and LLMs to interact with native iOS/Android applicati
|
|
|
7
7
|
<a href="https://github.com/mobile-next/mobile-mcp">
|
|
8
8
|
<img src="https://img.shields.io/github/stars/mobile-next/mobile-mcp" alt="Mobile Next Stars" />
|
|
9
9
|
</a>
|
|
10
|
-
<a href="https://github.com/mobile-next/mobile-mcp">
|
|
11
|
-
<img src="https://img.shields.io/github/contributors/mobile-next/mobile-mcp?color=green" alt="Mobile Next Downloads" />
|
|
12
|
-
</a>
|
|
13
10
|
<a href="https://www.npmjs.com/package/@mobilenext/mobile-mcp">
|
|
14
11
|
<img src="https://img.shields.io/npm/dm/@mobilenext/mobile-mcp?logo=npm&style=flat&color=red" alt="npm" />
|
|
15
12
|
</a>
|
|
16
13
|
<a href="https://github.com/mobile-next/mobile-mcp/releases">
|
|
17
14
|
<img src="https://img.shields.io/github/release/mobile-next/mobile-mcp" />
|
|
18
15
|
</a>
|
|
19
|
-
<a href="https://github.com/mobile-next/mobile-mcp/blob/main/LICENSE">
|
|
20
|
-
<img src="https://img.shields.io/badge/license-Apache 2.0-blue.svg" alt="Mobile MCP is released under the Apache-2.0 License" />
|
|
21
|
-
</a>
|
|
22
16
|
<a href="https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%7B%22name%22%3A%22mobile-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40mobilenext%2Fmobile-mcp%40latest%22%5D%7D">
|
|
23
17
|
<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code" />
|
|
24
18
|
</a>
|
|
25
|
-
</h4>
|
|
26
|
-
|
|
27
|
-
<h4 align="center">
|
|
28
19
|
<a href="https://github.com/mobile-next/mobile-mcp/wiki">
|
|
29
20
|
<img src="https://img.shields.io/badge/documentation-wiki-blue" alt="wiki" />
|
|
30
21
|
</a>
|
package/lib/server.js
CHANGED
|
@@ -18,6 +18,9 @@ const png_1 = require("./png");
|
|
|
18
18
|
const image_utils_1 = require("./image-utils");
|
|
19
19
|
const mobilecli_1 = require("./mobilecli");
|
|
20
20
|
const mobile_device_1 = require("./mobile-device");
|
|
21
|
+
const utils_1 = require("./utils");
|
|
22
|
+
const ALLOWED_SCREENSHOT_EXTENSIONS = [".png", ".jpg", ".jpeg"];
|
|
23
|
+
const ALLOWED_RECORDING_EXTENSIONS = [".mp4"];
|
|
21
24
|
const getAgentVersion = () => {
|
|
22
25
|
const json = require("../package.json");
|
|
23
26
|
return json.version;
|
|
@@ -348,6 +351,10 @@ const createMcpServer = () => {
|
|
|
348
351
|
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
349
352
|
url: zod_1.z.string().describe("The URL to open"),
|
|
350
353
|
}, { destructiveHint: true }, async ({ device, url }) => {
|
|
354
|
+
const allowUnsafeUrls = process.env.MOBILEMCP_ALLOW_UNSAFE_URLS === "1";
|
|
355
|
+
if (!allowUnsafeUrls && !url.startsWith("http://") && !url.startsWith("https://")) {
|
|
356
|
+
throw new robot_1.ActionableError("Only http:// and https:// URLs are allowed. Set MOBILEMCP_ALLOW_UNSAFE_URLS=1 to allow other URL schemes.");
|
|
357
|
+
}
|
|
351
358
|
const robot = getRobotFromDevice(device);
|
|
352
359
|
await robot.openUrl(url);
|
|
353
360
|
return `Opened URL: ${url}`;
|
|
@@ -386,8 +393,10 @@ const createMcpServer = () => {
|
|
|
386
393
|
});
|
|
387
394
|
tool("mobile_save_screenshot", "Save Screenshot", "Save a screenshot of the mobile device to a file", {
|
|
388
395
|
device: zod_1.z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
|
|
389
|
-
saveTo: zod_1.z.string().describe("The path to save the screenshot to"),
|
|
396
|
+
saveTo: zod_1.z.string().describe("The path to save the screenshot to. Filename must end with .png, .jpg, or .jpeg"),
|
|
390
397
|
}, { destructiveHint: true }, async ({ device, saveTo }) => {
|
|
398
|
+
(0, utils_1.validateFileExtension)(saveTo, ALLOWED_SCREENSHOT_EXTENSIONS, "save_screenshot");
|
|
399
|
+
(0, utils_1.validateOutputPath)(saveTo);
|
|
391
400
|
const robot = getRobotFromDevice(device);
|
|
392
401
|
const screenshot = await robot.getScreenshot();
|
|
393
402
|
node_fs_1.default.writeFileSync(saveTo, screenshot);
|
|
@@ -463,9 +472,13 @@ const createMcpServer = () => {
|
|
|
463
472
|
});
|
|
464
473
|
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
474
|
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."),
|
|
475
|
+
output: zod_1.z.string().optional().describe("The file path to save the recording to. Filename must end with .mp4. If not provided, a temporary path will be used."),
|
|
467
476
|
timeLimit: zod_1.z.coerce.number().optional().describe("Maximum recording duration in seconds. The recording will stop automatically after this time."),
|
|
468
477
|
}, { destructiveHint: true }, async ({ device, output, timeLimit }) => {
|
|
478
|
+
if (output) {
|
|
479
|
+
(0, utils_1.validateFileExtension)(output, ALLOWED_RECORDING_EXTENSIONS, "start_screen_recording");
|
|
480
|
+
(0, utils_1.validateOutputPath)(output);
|
|
481
|
+
}
|
|
469
482
|
getRobotFromDevice(device);
|
|
470
483
|
if (activeRecordings.has(device)) {
|
|
471
484
|
throw new robot_1.ActionableError(`Device "${device}" is already being recorded. Stop the current recording first with mobile_stop_screen_recording.`);
|
package/lib/utils.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.validatePackageName = validatePackageName;
|
|
4
7
|
exports.validateLocale = validateLocale;
|
|
8
|
+
exports.validateFileExtension = validateFileExtension;
|
|
9
|
+
exports.validateOutputPath = validateOutputPath;
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
5
13
|
const robot_1 = require("./robot");
|
|
6
14
|
function validatePackageName(packageName) {
|
|
7
15
|
if (!/^[a-zA-Z0-9._]+$/.test(packageName)) {
|
|
@@ -13,3 +21,60 @@ function validateLocale(locale) {
|
|
|
13
21
|
throw new robot_1.ActionableError(`Invalid locale: "${locale}"`);
|
|
14
22
|
}
|
|
15
23
|
}
|
|
24
|
+
function getAllowedRoots() {
|
|
25
|
+
const roots = [
|
|
26
|
+
node_os_1.default.tmpdir(),
|
|
27
|
+
process.cwd(),
|
|
28
|
+
];
|
|
29
|
+
// macOS /tmp is a symlink to /private/tmp, add both to be safe
|
|
30
|
+
if (process.platform === "darwin") {
|
|
31
|
+
roots.push("/tmp");
|
|
32
|
+
roots.push("/private/tmp");
|
|
33
|
+
}
|
|
34
|
+
return roots.map(r => node_path_1.default.resolve(r));
|
|
35
|
+
}
|
|
36
|
+
function isPathUnderRoot(filePath, root) {
|
|
37
|
+
const relative = node_path_1.default.relative(root, filePath);
|
|
38
|
+
if (relative === "") {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (node_path_1.default.isAbsolute(relative)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (relative.startsWith("..")) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
function validateFileExtension(filePath, allowedExtensions, toolName) {
|
|
50
|
+
const ext = node_path_1.default.extname(filePath).toLowerCase();
|
|
51
|
+
if (!allowedExtensions.includes(ext)) {
|
|
52
|
+
throw new robot_1.ActionableError(`${toolName} requires a ${allowedExtensions.join(", ")} file extension, got: "${ext || "(none)"}"`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function resolveWithSymlinks(filePath) {
|
|
56
|
+
const resolved = node_path_1.default.resolve(filePath);
|
|
57
|
+
const dir = node_path_1.default.dirname(resolved);
|
|
58
|
+
const filename = node_path_1.default.basename(resolved);
|
|
59
|
+
try {
|
|
60
|
+
return node_path_1.default.join(node_fs_1.default.realpathSync(dir), filename);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return resolved;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function validateOutputPath(filePath) {
|
|
67
|
+
const resolved = resolveWithSymlinks(filePath);
|
|
68
|
+
const allowedRoots = getAllowedRoots();
|
|
69
|
+
const isWindows = process.platform === "win32";
|
|
70
|
+
const isAllowed = allowedRoots.some(root => {
|
|
71
|
+
if (isWindows) {
|
|
72
|
+
return isPathUnderRoot(resolved.toLowerCase(), root.toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
return isPathUnderRoot(resolved, root);
|
|
75
|
+
});
|
|
76
|
+
if (!isAllowed) {
|
|
77
|
+
const dir = node_path_1.default.dirname(resolved);
|
|
78
|
+
throw new robot_1.ActionableError(`"${dir}" is not in the list of allowed directories. Allowed directories include the current directory and the temp directory on this host.`);
|
|
79
|
+
}
|
|
80
|
+
}
|