Replaces single-file server with modular mixin architecture: - 6 domain mixins (devices, input, apps, screenshot, ui, files) - Injection-safe run_shell_args() using shlex.quote() for all tools - Persistent developer mode config (~/.config/adb-mcp/config.json) - Pydantic models for typed responses - MCP elicitation for destructive operations - Dynamic screen dimensions for scroll gestures - Intent flag name resolution for activity_start - 50 tools, 5 resources, tested on real hardware
570 lines
17 KiB
Python
570 lines
17 KiB
Python
"""Apps mixin for Android ADB MCP Server.
|
|
|
|
Provides tools for app management and launching.
|
|
"""
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
from fastmcp import Context
|
|
from fastmcp.contrib.mcp_mixin import mcp_resource, mcp_tool
|
|
|
|
from ..config import is_developer_mode
|
|
from .base import ADBBaseMixin
|
|
|
|
# Common Android intent flags (hex values for am start -f)
|
|
_INTENT_FLAGS: dict[str, int] = {
|
|
"FLAG_ACTIVITY_NEW_TASK": 0x10000000,
|
|
"FLAG_ACTIVITY_CLEAR_TOP": 0x04000000,
|
|
"FLAG_ACTIVITY_SINGLE_TOP": 0x20000000,
|
|
"FLAG_ACTIVITY_NO_HISTORY": 0x40000000,
|
|
"FLAG_ACTIVITY_CLEAR_TASK": 0x00008000,
|
|
"FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS": 0x00800000,
|
|
"FLAG_ACTIVITY_FORWARD_RESULT": 0x02000000,
|
|
"FLAG_ACTIVITY_MULTIPLE_TASK": 0x08000000,
|
|
}
|
|
|
|
|
|
class AppsMixin(ADBBaseMixin):
|
|
"""Mixin for Android app management.
|
|
|
|
Provides tools for:
|
|
- Launching apps
|
|
- Opening URLs
|
|
- Listing packages (developer mode)
|
|
- Installing/uninstalling apps (developer mode)
|
|
"""
|
|
|
|
@mcp_tool()
|
|
async def app_launch(
|
|
self,
|
|
package_name: str,
|
|
device_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Launch an app by package name.
|
|
|
|
Starts the main activity of the specified application.
|
|
|
|
Common package names:
|
|
- com.android.chrome - Chrome browser
|
|
- com.android.settings - Settings
|
|
- com.android.vending - Play Store
|
|
- com.google.android.gm - Gmail
|
|
- com.google.android.apps.maps - Google Maps
|
|
|
|
Args:
|
|
package_name: Android package name (e.g., com.android.chrome)
|
|
device_id: Target device
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
result = await self.run_shell_args(
|
|
[
|
|
"monkey",
|
|
"-p",
|
|
package_name,
|
|
"-c",
|
|
"android.intent.category.LAUNCHER",
|
|
"1",
|
|
],
|
|
device_id,
|
|
)
|
|
return {
|
|
"success": result.success,
|
|
"action": "launch",
|
|
"package": package_name,
|
|
"error": result.stderr if not result.success else None,
|
|
}
|
|
|
|
@mcp_tool()
|
|
async def app_open_url(
|
|
self,
|
|
url: str,
|
|
device_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Open a URL in the default browser.
|
|
|
|
Launches the default browser and navigates to the URL.
|
|
Supports http://, https://, and other URL schemes.
|
|
|
|
Args:
|
|
url: URL to open (e.g., https://example.com)
|
|
device_id: Target device
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
result = await self.run_shell_args(
|
|
[
|
|
"am",
|
|
"start",
|
|
"-a",
|
|
"android.intent.action.VIEW",
|
|
"-d",
|
|
url,
|
|
],
|
|
device_id,
|
|
)
|
|
return {
|
|
"success": result.success,
|
|
"action": "open_url",
|
|
"url": url,
|
|
"error": result.stderr if not result.success else None,
|
|
}
|
|
|
|
@mcp_tool()
|
|
async def app_close(
|
|
self,
|
|
package_name: str,
|
|
device_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Force stop an app.
|
|
|
|
Stops the application and all its background services.
|
|
|
|
Args:
|
|
package_name: Package name to stop
|
|
device_id: Target device
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
result = await self.run_shell_args(
|
|
["am", "force-stop", package_name], device_id
|
|
)
|
|
return {
|
|
"success": result.success,
|
|
"action": "close",
|
|
"package": package_name,
|
|
"error": result.stderr if not result.success else None,
|
|
}
|
|
|
|
@mcp_tool()
|
|
async def app_current(
|
|
self,
|
|
device_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Get the currently focused app.
|
|
|
|
Returns the package name of the app currently in foreground.
|
|
|
|
Args:
|
|
device_id: Target device
|
|
|
|
Returns:
|
|
Current app package and activity
|
|
"""
|
|
# Use plain "dumpsys window" — the focus fields live at the
|
|
# top level, not inside the "windows" subsection on many devices
|
|
result = await self.run_shell_args(["dumpsys", "window"], device_id)
|
|
|
|
if result.success:
|
|
package = None
|
|
activity = None
|
|
for line in result.stdout.split("\n"):
|
|
if "mFocusedApp" in line or "mCurrentFocus" in line:
|
|
# Match both formats:
|
|
# mFocusedApp=ActivityRecord{... com.pkg/.Act t123}
|
|
# mCurrentFocus=Window{... com.pkg/com.pkg.Act}
|
|
match = re.search(r"([\w.]+)/([\w.]*\.?\w+)", line)
|
|
if match:
|
|
package = match.group(1)
|
|
activity = match.group(2)
|
|
break
|
|
return {
|
|
"success": True,
|
|
"package": package,
|
|
"activity": activity,
|
|
"raw": result.stdout[:500] if not package else None,
|
|
}
|
|
|
|
return {
|
|
"success": False,
|
|
"error": result.stderr,
|
|
}
|
|
|
|
# === Developer Mode Tools ===
|
|
|
|
@mcp_tool(
|
|
tags={"developer"},
|
|
annotations={"requires": "developer_mode"},
|
|
)
|
|
async def app_list_packages(
|
|
self,
|
|
filter_text: str | None = None,
|
|
system_only: bool = False,
|
|
third_party_only: bool = False,
|
|
device_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""List installed packages.
|
|
|
|
[DEVELOPER MODE] Retrieves all installed application packages.
|
|
|
|
Args:
|
|
filter_text: Filter packages containing this text
|
|
system_only: Only show system packages
|
|
third_party_only: Only show third-party (user installed) packages
|
|
device_id: Target device
|
|
|
|
Returns:
|
|
List of package names
|
|
"""
|
|
if not is_developer_mode():
|
|
return {
|
|
"success": False,
|
|
"error": "Developer mode required",
|
|
}
|
|
|
|
cmd = ["pm", "list", "packages"]
|
|
if system_only:
|
|
cmd.append("-s")
|
|
elif third_party_only:
|
|
cmd.append("-3")
|
|
|
|
result = await self.run_shell_args(cmd, device_id)
|
|
|
|
if result.success:
|
|
packages = []
|
|
for line in result.stdout.split("\n"):
|
|
if line.startswith("package:"):
|
|
pkg = line.replace("package:", "").strip()
|
|
if filter_text is None or filter_text.lower() in pkg.lower():
|
|
packages.append(pkg)
|
|
|
|
return {
|
|
"success": True,
|
|
"packages": sorted(packages),
|
|
"count": len(packages),
|
|
}
|
|
|
|
return {
|
|
"success": False,
|
|
"error": result.stderr,
|
|
}
|
|
|
|
@mcp_tool(
|
|
tags={"developer"},
|
|
annotations={"requires": "developer_mode"},
|
|
)
|
|
async def app_install(
|
|
self,
|
|
apk_path: str,
|
|
device_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Install an APK file.
|
|
|
|
[DEVELOPER MODE] Installs an APK from the host machine to the device.
|
|
|
|
Args:
|
|
apk_path: Path to APK file on host machine
|
|
device_id: Target device
|
|
|
|
Returns:
|
|
Installation result
|
|
"""
|
|
if not is_developer_mode():
|
|
return {
|
|
"success": False,
|
|
"error": "Developer mode required",
|
|
}
|
|
|
|
result = await self.run_adb(["install", "-r", apk_path], device_id)
|
|
return {
|
|
"success": result.success,
|
|
"action": "install",
|
|
"apk": apk_path,
|
|
"output": result.stdout,
|
|
"error": result.stderr if not result.success else None,
|
|
}
|
|
|
|
@mcp_tool(
|
|
tags={"developer"},
|
|
annotations={"requires": "developer_mode"},
|
|
)
|
|
async def app_uninstall(
|
|
self,
|
|
ctx: Context,
|
|
package_name: str,
|
|
keep_data: bool = False,
|
|
device_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Uninstall an app.
|
|
|
|
[DEVELOPER MODE] Removes an application from the device.
|
|
Requires user confirmation before proceeding.
|
|
|
|
Args:
|
|
ctx: MCP context for elicitation/logging
|
|
package_name: Package to uninstall
|
|
keep_data: Keep app data after uninstall
|
|
device_id: Target device
|
|
|
|
Returns:
|
|
Uninstall result
|
|
"""
|
|
if not is_developer_mode():
|
|
return {
|
|
"success": False,
|
|
"error": "Developer mode required",
|
|
}
|
|
|
|
# Elicit confirmation
|
|
await ctx.warning(f"Uninstall requested: {package_name}")
|
|
|
|
data_note = " (keeping app data)" if keep_data else " (all data will be lost)"
|
|
confirmation = await ctx.elicit(
|
|
f"Are you sure you want to uninstall '{package_name}'?{data_note}",
|
|
["Yes, uninstall", "Cancel"],
|
|
)
|
|
|
|
if confirmation.action != "accept" or confirmation.content == "Cancel":
|
|
await ctx.info("Uninstall cancelled by user")
|
|
return {
|
|
"success": False,
|
|
"cancelled": True,
|
|
"message": "Uninstall cancelled by user",
|
|
}
|
|
|
|
await ctx.info(f"Uninstalling {package_name}...")
|
|
|
|
cmd = ["uninstall"]
|
|
if keep_data:
|
|
cmd.append("-k")
|
|
cmd.append(package_name)
|
|
|
|
result = await self.run_adb(cmd, device_id)
|
|
|
|
if result.success:
|
|
await ctx.info(f"Successfully uninstalled {package_name}")
|
|
else:
|
|
await ctx.error(f"Uninstall failed: {result.stderr}")
|
|
|
|
return {
|
|
"success": result.success,
|
|
"action": "uninstall",
|
|
"package": package_name,
|
|
"kept_data": keep_data,
|
|
"error": result.stderr if not result.success else None,
|
|
}
|
|
|
|
@mcp_tool(
|
|
tags={"developer"},
|
|
annotations={"requires": "developer_mode"},
|
|
)
|
|
async def app_clear_data(
|
|
self,
|
|
ctx: Context,
|
|
package_name: str,
|
|
device_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Clear app data and cache.
|
|
|
|
[DEVELOPER MODE] Clears all data for an application (like a fresh
|
|
install). Requires user confirmation before proceeding.
|
|
|
|
Args:
|
|
ctx: MCP context for elicitation/logging
|
|
package_name: Package to clear
|
|
device_id: Target device
|
|
|
|
Returns:
|
|
Clear result
|
|
"""
|
|
if not is_developer_mode():
|
|
return {
|
|
"success": False,
|
|
"error": "Developer mode required",
|
|
}
|
|
|
|
# Elicit confirmation
|
|
await ctx.warning(f"Clear data requested: {package_name}")
|
|
|
|
confirmation = await ctx.elicit(
|
|
f"Are you sure you want to clear ALL data for "
|
|
f"'{package_name}'? This includes login state, settings, "
|
|
"saved files, and cache. The app will be reset to a "
|
|
"fresh install state.",
|
|
["Yes, clear all data", "Cancel"],
|
|
)
|
|
|
|
if confirmation.action != "accept" or confirmation.content == "Cancel":
|
|
await ctx.info("Clear data cancelled by user")
|
|
return {
|
|
"success": False,
|
|
"cancelled": True,
|
|
"message": "Clear data cancelled by user",
|
|
}
|
|
|
|
await ctx.info(f"Clearing data for {package_name}...")
|
|
|
|
result = await self.run_shell_args(["pm", "clear", package_name], device_id)
|
|
|
|
if result.success:
|
|
await ctx.info(f"Successfully cleared data for {package_name}")
|
|
else:
|
|
await ctx.error(f"Clear data failed: {result.stderr}")
|
|
|
|
return {
|
|
"success": result.success,
|
|
"action": "clear_data",
|
|
"package": package_name,
|
|
"error": result.stderr if not result.success else None,
|
|
}
|
|
|
|
@mcp_tool(
|
|
tags={"developer"},
|
|
annotations={"requires": "developer_mode"},
|
|
)
|
|
async def activity_start(
|
|
self,
|
|
component: str,
|
|
action: str | None = None,
|
|
data_uri: str | None = None,
|
|
extras: dict[str, str] | None = None,
|
|
flags: list[str] | None = None,
|
|
device_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Start a specific activity with intent.
|
|
|
|
[DEVELOPER MODE] Launch an activity with full intent control.
|
|
More powerful than app_launch for deep linking and testing.
|
|
|
|
Args:
|
|
component: Activity component (e.g., "com.example/.MainActivity")
|
|
action: Intent action (e.g., "android.intent.action.VIEW")
|
|
data_uri: Data URI to pass to activity
|
|
extras: Extra key-value pairs to include in intent
|
|
flags: Intent flag names (e.g., ["FLAG_ACTIVITY_NEW_TASK"])
|
|
or hex values (e.g., ["0x10000000"])
|
|
device_id: Target device
|
|
|
|
Returns:
|
|
Activity start result
|
|
|
|
Examples:
|
|
Start specific activity:
|
|
component="com.example/.MainActivity"
|
|
Deep link with data:
|
|
component="com.example/.DeepLinkActivity"
|
|
data_uri="myapp://product/123"
|
|
"""
|
|
if not is_developer_mode():
|
|
return {
|
|
"success": False,
|
|
"error": "Developer mode required",
|
|
}
|
|
|
|
cmd_args = ["am", "start"]
|
|
|
|
if action:
|
|
cmd_args.extend(["-a", action])
|
|
|
|
if data_uri:
|
|
cmd_args.extend(["-d", data_uri])
|
|
|
|
# Resolve flag names to integer values for am start -f
|
|
if flags:
|
|
combined_flags = 0
|
|
for flag in flags:
|
|
flag_clean = flag.strip()
|
|
if flag_clean.startswith("0x"):
|
|
combined_flags |= int(flag_clean, 16)
|
|
elif flag_clean in _INTENT_FLAGS:
|
|
combined_flags |= _INTENT_FLAGS[flag_clean]
|
|
elif flag_clean.isdigit():
|
|
combined_flags |= int(flag_clean)
|
|
if combined_flags:
|
|
cmd_args.extend(["-f", str(combined_flags)])
|
|
|
|
if extras:
|
|
for key, value in extras.items():
|
|
if value.lower() in ("true", "false"):
|
|
cmd_args.extend(["--ez", key, value.lower()])
|
|
elif re.match(r"^-?\d+$", value):
|
|
cmd_args.extend(["--ei", key, value])
|
|
else:
|
|
cmd_args.extend(["--es", key, value])
|
|
|
|
cmd_args.extend(["-n", component])
|
|
|
|
result = await self.run_shell_args(cmd_args, device_id)
|
|
|
|
return {
|
|
"success": result.success,
|
|
"action": "activity_start",
|
|
"component": component,
|
|
"intent_action": action,
|
|
"data_uri": data_uri,
|
|
"output": result.stdout,
|
|
"error": result.stderr if not result.success else None,
|
|
}
|
|
|
|
@mcp_tool(
|
|
tags={"developer"},
|
|
annotations={"requires": "developer_mode"},
|
|
)
|
|
async def broadcast_send(
|
|
self,
|
|
action: str,
|
|
extras: dict[str, str] | None = None,
|
|
package: str | None = None,
|
|
device_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Send a broadcast intent.
|
|
|
|
[DEVELOPER MODE] Sends a broadcast that can be received by
|
|
BroadcastReceivers. Useful for testing and triggering app behavior.
|
|
|
|
Args:
|
|
action: Broadcast action (e.g., "com.example.MY_ACTION")
|
|
extras: Extra key-value pairs to include
|
|
package: Limit to specific package (optional)
|
|
device_id: Target device
|
|
|
|
Returns:
|
|
Broadcast result
|
|
|
|
Common system broadcasts (for testing receivers):
|
|
- android.intent.action.AIRPLANE_MODE
|
|
- android.intent.action.BATTERY_LOW
|
|
- android.net.conn.CONNECTIVITY_CHANGE
|
|
"""
|
|
if not is_developer_mode():
|
|
return {
|
|
"success": False,
|
|
"error": "Developer mode required",
|
|
}
|
|
|
|
cmd_args = ["am", "broadcast", "-a", action]
|
|
|
|
if package:
|
|
cmd_args.extend(["-p", package])
|
|
|
|
if extras:
|
|
for key, value in extras.items():
|
|
if value.lower() in ("true", "false"):
|
|
cmd_args.extend(["--ez", key, value.lower()])
|
|
elif re.match(r"^-?\d+$", value):
|
|
cmd_args.extend(["--ei", key, value])
|
|
else:
|
|
cmd_args.extend(["--es", key, value])
|
|
|
|
result = await self.run_shell_args(cmd_args, device_id)
|
|
|
|
return {
|
|
"success": result.success,
|
|
"action": "broadcast_send",
|
|
"broadcast_action": action,
|
|
"package": package,
|
|
"output": result.stdout,
|
|
"error": result.stderr if not result.success else None,
|
|
}
|
|
|
|
# === Resources ===
|
|
|
|
@mcp_resource(uri="adb://apps/current")
|
|
async def resource_current_app(self) -> dict[str, Any]:
|
|
"""Resource: Get currently focused app."""
|
|
return await self.app_current()
|