Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ How we help to scale mobile automation:

### App Management
- **`mobile_list_apps`** - List all installed apps on the device
- **`mobile_launch_app`** - Launch an app using its package name
- **`mobile_launch_app`** - Launch an app using its package name, optionally with a locale and launch arguments (e.g. feature flags)
- **`mobile_terminate_app`** - Stop and terminate a running app
- **`mobile_install_app`** - Install an app from file (.apk, .ipa, .app, .zip)
- **`mobile_uninstall_app`** - Uninstall an app using bundle ID or package name
Expand Down
64 changes: 62 additions & 2 deletions src/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { existsSync } from "node:fs";
import * as xml from "fast-xml-parser";

import { ActionableError, Button, InstalledApp, Robot, ScreenElement, ScreenElementRect, ScreenSize, SwipeDirection, Orientation } from "./robot";
import { validatePackageName, validateLocale } from "./utils";
import { validatePackageName, validateLocale, validateLaunchArgs } from "./utils";

export interface AndroidDevice {
deviceId: string;
Expand Down Expand Up @@ -145,25 +145,85 @@ export class AndroidRobot implements Robot {
.map(line => line.substring("package:".length));
}

public async launchApp(packageName: string, locale?: string): Promise<void> {
public async launchApp(packageName: string, locale?: string, launchArgs?: Record<string, string>): Promise<void> {
validatePackageName(packageName);

if (locale) {
validateLocale(locale);
}

if (launchArgs && Object.keys(launchArgs).length > 0) {
validateLaunchArgs(launchArgs);
}

if (locale) {
try {
this.silentAdb("shell", "cmd", "locale", "set-app-locales", packageName, "--locales", locale);
} catch (error) {
// set-app-locales requires Android 13+ (API 33), silently ignore on older versions
}
}

if (launchArgs && Object.keys(launchArgs).length > 0) {
// launch arguments require `am start` with an explicit component, so resolve
// the launcher activity and pass the extras as string extras (--es).
await this.launchWithExtras(packageName, launchArgs);
return;
}

try {
this.silentAdb("shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1");
} catch (error) {
throw new ActionableError(`Failed launching app with package name "${packageName}", please make sure it exists`);
}
}

private resolveLauncherActivity(packageName: string): string {
let text: string;
try {
text = this.adb("shell", "cmd", "package", "resolve-activity", "--brief", "-c", "android.intent.category.LAUNCHER", packageName).toString();
} catch (error) {
throw new ActionableError(`Failed resolving a launchable activity for package "${packageName}": ${this.commandErrorDetails(error)}`);
}

const componentPattern = /^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\/[a-zA-Z0-9_.$]+$/;
const components = text
.split("\n")
.map(line => line.trim())
.filter(line => componentPattern.test(line));

if (components.length !== 1) {
throw new ActionableError(`Could not resolve a launchable activity for package "${packageName}", please make sure it exists`);
}

return components[0];
}

private async launchWithExtras(packageName: string, launchArgs: Record<string, string>): Promise<void> {
const component = this.resolveLauncherActivity(packageName);
const args = ["shell", "am", "start", "-n", component];
for (const [key, value] of Object.entries(launchArgs)) {
args.push("--es", key, this.quoteShellArgument(value));
}

try {
this.silentAdb(...args);
} catch (error) {
throw new ActionableError(`Failed launching app with package name "${packageName}": ${this.commandErrorDetails(error)}`);
}
}

private quoteShellArgument(text: string): string {
return `'${text.replace(/'/g, "'\\''")}'`;
}

private commandErrorDetails(error: unknown): string {
const commandError = error as { stdout?: Buffer | string; stderr?: Buffer | string; message?: string };
const stdout = commandError.stdout?.toString() || "";
const stderr = commandError.stderr?.toString() || "";
return (stdout + stderr).trim() || commandError.message || String(error);
}

public async listRunningProcesses(): Promise<string[]> {
return this.adb("shell", "ps", "-e")
.toString()
Expand Down
28 changes: 23 additions & 5 deletions src/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";

import { WebDriverAgent } from "./webdriver-agent";
import { ActionableError, Button, InstalledApp, Robot, ScreenSize, SwipeDirection, ScreenElement, Orientation } from "./robot";
import { validatePackageName, validateLocale } from "./utils";
import { validatePackageName, validateLocale, validateLaunchArgs } from "./utils";

const WDA_PORT = 8100;
const IOS_TUNNEL_PORT = 60105;
Expand Down Expand Up @@ -138,15 +138,33 @@ export class IosRobot implements Robot {
});
}

public async launchApp(packageName: string, locale?: string): Promise<void> {
public async launchApp(packageName: string, locale?: string, launchArgs?: Record<string, string>): Promise<void> {
validatePackageName(packageName);

if (locale) {
validateLocale(locale);
}

if (launchArgs && Object.keys(launchArgs).length > 0) {
validateLaunchArgs(launchArgs);
}

await this.assertTunnelRunning();
const args = ["launch", packageName];
if (locale) {
validateLocale(locale);
const locales = locale.split(",").map(l => l.trim());
args.push("-AppleLanguages", `(${locales.join(", ")})`);
args.push("-AppleLocale", locales[0]);
args.push("--arg=-AppleLanguages", `--arg=(${locales.join(", ")})`);
args.push("--arg=-AppleLocale", `--arg=${locales[0]}`);
}

if (launchArgs && Object.keys(launchArgs).length > 0) {
// go-ios passes launch arguments through the repeatable `--arg` flag (see
// `ios launch <bundleID> [--arg=<a>]...`). Each `-key value` pair becomes two
// array elements, mirroring Xcode's "Arguments Passed On Launch". Requires the
// Developer Disk Image mounted, like other go-ios instruments features.
for (const [key, value] of Object.entries(launchArgs)) {
args.push(`--arg=-${key}`, `--arg=${value}`);
}
}

await this.ios(...args);
Expand Down
14 changes: 12 additions & 2 deletions src/iphone-simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { join, basename, extname } from "node:path";
import { trace } from "./logger";
import { WebDriverAgent } from "./webdriver-agent";
import { ActionableError, Button, InstalledApp, Robot, ScreenElement, ScreenSize, SwipeDirection, Orientation } from "./robot";
import { validatePackageName, validateLocale } from "./utils";
import { validatePackageName, validateLocale, validateLaunchArgs } from "./utils";

export interface Simulator {
name: string;
Expand Down Expand Up @@ -102,7 +102,7 @@ export class Simctl implements Robot {
// alternative: this.simctl("openurl", this.simulatorUuid, url);
}

public async launchApp(packageName: string, locale?: string) {
public async launchApp(packageName: string, locale?: string, launchArgs?: Record<string, string>) {
validatePackageName(packageName);
const args = ["launch", this.simulatorUuid, packageName];
if (locale) {
Expand All @@ -112,6 +112,16 @@ export class Simctl implements Robot {
args.push("-AppleLocale", locales[0]);
}

if (launchArgs && Object.keys(launchArgs).length > 0) {
// simctl passes trailing arguments to the app as launch arguments, identical
// to Xcode's "Arguments Passed On Launch". `-key value` pairs are parsed into
// UserDefaults by the OS, so no app changes are needed to read them.
validateLaunchArgs(launchArgs);
for (const [key, value] of Object.entries(launchArgs)) {
args.push(`-${key}`, value);
}
}

this.simctl(...args);
}

Expand Down
10 changes: 9 additions & 1 deletion src/mobile-device.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Mobilecli } from "./mobilecli";
import { Simctl } from "./iphone-simulator";
import { Button, InstalledApp, Orientation, Robot, ScreenElement, ScreenSize, SwipeDirection } from "./robot";

interface InstalledAppsResponse {
Expand Down Expand Up @@ -62,9 +63,11 @@ interface OrientationResponse {
export class MobileDevice implements Robot {

private mobilecli: Mobilecli;
private simctl: Simctl;

public constructor(private deviceId: string) {
this.mobilecli = new Mobilecli();
this.simctl = new Simctl(deviceId);
}

private runCommand(args: string[]): string {
Expand Down Expand Up @@ -149,12 +152,17 @@ export class MobileDevice implements Robot {
})) as InstalledApp[];
}

public async launchApp(packageName: string, locale?: string): Promise<void> {
public async launchApp(packageName: string, locale?: string, launchArgs?: Record<string, string>): Promise<void> {
const args = ["apps", "launch", packageName];
if (locale) {
args.push("--locale", locale);
}

if (launchArgs && Object.keys(launchArgs).length > 0) {
await this.simctl.launchApp(packageName, locale, launchArgs);
return;
}

this.runCommand(args);
}

Expand Down
12 changes: 10 additions & 2 deletions src/robot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,16 @@ export interface Robot {

/**
* Launch an app.
*/
launchApp(packageName: string, locale?: string): Promise<void>;
*
* @param packageName The package name (Android) or bundle id (iOS) of the app.
* @param locale Optional comma-separated BCP 47 locale tags to launch with.
* @param launchArgs Optional key/value pairs passed to the app at launch. On iOS
* these become launch arguments (`-key value`), parsed into `UserDefaults` — the
* same mechanism as Xcode's "Arguments Passed On Launch". On Android they become
* string intent extras (`am start --es key value`), which the app must read from
* its launch intent.
*/
launchApp(packageName: string, locale?: string, launchArgs?: Record<string, string>): Promise<void>;

/**
* Terminate an app. If app was already terminated (or non existent) then this
Expand Down
24 changes: 19 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { PNG } from "./png";
import { isScalingAvailable, Image } from "./image-utils";
import { Mobilecli } from "./mobilecli";
import { MobileDevice } from "./mobile-device";
import { validateOutputPath, validateFileExtension } from "./utils";
import { validateOutputPath, validateFileExtension, validatePackageName, validateLocale, validateLaunchArgs } from "./utils";

const ALLOWED_SCREENSHOT_EXTENSIONS = [".png", ".jpg", ".jpeg"];
const ALLOWED_RECORDING_EXTENSIONS = [".mp4"];
Expand All @@ -38,12 +38,16 @@ interface ActiveRecording {
startedAt: number;
}

export interface McpServerDependencies {
getRobotFromDevice?: (deviceId: string) => Robot;
}

export const getAgentVersion = (): string => {
const json = require("../package.json");
return json.version;
};

export const createMcpServer = (): McpServer => {
export const createMcpServer = (dependencies: McpServerDependencies = {}): McpServer => {

const server = new McpServer({
name: "mobile-mcp",
Expand Down Expand Up @@ -163,7 +167,7 @@ export const createMcpServer = (): McpServer => {
}
};

const getRobotFromDevice = (deviceId: string): Robot => {
const defaultGetRobotFromDevice = (deviceId: string): Robot => {

// from now on, we must have mobilecli working
ensureMobilecliAvailable();
Expand Down Expand Up @@ -213,6 +217,7 @@ export const createMcpServer = (): McpServer => {

throw new ActionableError(`Device "${deviceId}" not found. Use the mobile_list_available_devices tool to see available devices.`);
};
const getRobotFromDevice = dependencies.getRobotFromDevice || defaultGetRobotFromDevice;

tool(
"mobile_list_available_devices",
Expand Down Expand Up @@ -361,11 +366,20 @@ export const createMcpServer = (): McpServer => {
device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."),
packageName: z.string().describe("The package name of the app to launch"),
locale: z.string().optional().describe("Comma-separated BCP 47 locale tags to launch the app with (e.g., fr-FR,en-GB)"),
launchArgs: z.record(z.string(), z.string()).optional().describe("Key/value pairs passed to the app at launch. On iOS these become launch arguments (readable via UserDefaults, like Xcode's \"Arguments Passed On Launch\"); on Android they become string intent extras the app reads from its launch intent."),
},
{ destructiveHint: true },
async ({ device, packageName, locale }) => {
async ({ device, packageName, locale, launchArgs }) => {
validatePackageName(packageName);
if (locale) {
validateLocale(locale);
}
if (launchArgs) {
validateLaunchArgs(launchArgs);
}

const robot = getRobotFromDevice(device);
await robot.launchApp(packageName, locale);
await robot.launchApp(packageName, locale, launchArgs);
return `Launched app ${packageName}`;
}
);
Expand Down
16 changes: 16 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ export function validateLocale(locale: string): void {
}
}

export function validateLaunchArgs(launchArgs: Record<string, string>): void {
for (const [key, value] of Object.entries(launchArgs)) {
// keys map to iOS launch argument flags and Android extra keys, so keep them
// to a conservative identifier-like charset.
if (!/^[a-zA-Z0-9._-]+$/.test(key)) {
throw new ActionableError(`Invalid launch argument key: "${key}"`);
}

// Node child processes reject NUL in argv. Platform-specific command
// boundaries are responsible for preserving all other string content.
if (value.includes("\0")) {
throw new ActionableError(`Invalid launch argument value for "${key}": "${value}"`);
}
}
}

function resolveRoot(root: string): string {
const resolved = path.resolve(root);

Expand Down
Loading