Skip to content

Commit 40a8e76

Browse files
committed
Merge branch 'main' of github.com:mobile-next/mobile-mcp
2 parents bf1a0bd + f5e3229 commit 40a8e76

2 files changed

Lines changed: 89 additions & 2 deletions

File tree

src/server.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import { PNG } from "./png";
1414
import { isScalingAvailable, Image } from "./image-utils";
1515
import { Mobilecli } from "./mobilecli";
1616
import { MobileDevice } from "./mobile-device";
17+
import { validateOutputPath, validateFileExtension } from "./utils";
18+
19+
const ALLOWED_SCREENSHOT_EXTENSIONS = [".png", ".jpg", ".jpeg"];
20+
const ALLOWED_RECORDING_EXTENSIONS = [".mp4"];
1721

1822
interface MobilecliDevice {
1923
id: string;
@@ -581,10 +585,13 @@ export const createMcpServer = (): McpServer => {
581585
"Save a screenshot of the mobile device to a file",
582586
{
583587
device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
584-
saveTo: z.string().describe("The path to save the screenshot to"),
588+
saveTo: z.string().describe("The path to save the screenshot to. Filename must end with .png, .jpg, or .jpeg"),
585589
},
586590
{ destructiveHint: true },
587591
async ({ device, saveTo }) => {
592+
validateFileExtension(saveTo, ALLOWED_SCREENSHOT_EXTENSIONS, "save_screenshot");
593+
validateOutputPath(saveTo);
594+
588595
const robot = getRobotFromDevice(device);
589596

590597
const screenshot = await robot.getScreenshot();
@@ -694,11 +701,16 @@ export const createMcpServer = (): McpServer => {
694701
"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.",
695702
{
696703
device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
697-
output: z.string().optional().describe("The file path to save the recording to. If not provided, a temporary path will be used."),
704+
output: 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."),
698705
timeLimit: z.coerce.number().optional().describe("Maximum recording duration in seconds. The recording will stop automatically after this time."),
699706
},
700707
{ destructiveHint: true },
701708
async ({ device, output, timeLimit }) => {
709+
if (output) {
710+
validateFileExtension(output, ALLOWED_RECORDING_EXTENSIONS, "start_screen_recording");
711+
validateOutputPath(output);
712+
}
713+
702714
getRobotFromDevice(device);
703715

704716
if (activeRecordings.has(device)) {

src/utils.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import path from "node:path";
2+
import os from "node:os";
3+
import fs from "node:fs";
14
import { ActionableError } from "./robot";
25

36
export function validatePackageName(packageName: string): void {
@@ -11,3 +14,75 @@ export function validateLocale(locale: string): void {
1114
throw new ActionableError(`Invalid locale: "${locale}"`);
1215
}
1316
}
17+
18+
function getAllowedRoots(): string[] {
19+
const roots = [
20+
os.tmpdir(),
21+
process.cwd(),
22+
];
23+
24+
// macOS /tmp is a symlink to /private/tmp, add both to be safe
25+
if (process.platform === "darwin") {
26+
roots.push("/tmp");
27+
roots.push("/private/tmp");
28+
}
29+
30+
return roots.map(r => path.resolve(r));
31+
}
32+
33+
function isPathUnderRoot(filePath: string, root: string): boolean {
34+
const relative = path.relative(root, filePath);
35+
if (relative === "") {
36+
return false;
37+
}
38+
39+
if (path.isAbsolute(relative)) {
40+
return false;
41+
}
42+
43+
if (relative.startsWith("..")) {
44+
return false;
45+
}
46+
47+
return true;
48+
}
49+
50+
export function validateFileExtension(filePath: string, allowedExtensions: string[], toolName: string): void {
51+
const ext = path.extname(filePath).toLowerCase();
52+
if (!allowedExtensions.includes(ext)) {
53+
throw new ActionableError(`${toolName} requires a ${allowedExtensions.join(", ")} file extension, got: "${ext || "(none)"}"`);
54+
}
55+
}
56+
57+
function resolveWithSymlinks(filePath: string): string {
58+
const resolved = path.resolve(filePath);
59+
const dir = path.dirname(resolved);
60+
const filename = path.basename(resolved);
61+
62+
try {
63+
return path.join(fs.realpathSync(dir), filename);
64+
} catch {
65+
return resolved;
66+
}
67+
}
68+
69+
export function validateOutputPath(filePath: string): void {
70+
const resolved = resolveWithSymlinks(filePath);
71+
const allowedRoots = getAllowedRoots();
72+
const isWindows = process.platform === "win32";
73+
74+
const isAllowed = allowedRoots.some(root => {
75+
if (isWindows) {
76+
return isPathUnderRoot(resolved.toLowerCase(), root.toLowerCase());
77+
}
78+
79+
return isPathUnderRoot(resolved, root);
80+
});
81+
82+
if (!isAllowed) {
83+
const dir = path.dirname(resolved);
84+
throw new ActionableError(
85+
`"${dir}" is not in the list of allowed directories. Allowed directories include the current directory and the temp directory on this host.`
86+
);
87+
}
88+
}

0 commit comments

Comments
 (0)