Add connectivity and settings mixins (50 → 65 tools)
New mixins: - connectivity.py: adb_connect, adb_disconnect, adb_tcpip, adb_pair, device_properties (batch getprop) - settings.py: settings_get/put, wifi/bluetooth/airplane toggles, screen_brightness, screen_timeout, notification_list, clipboard_get, media_control Also fixes clipboard_set false-positive on devices where cmd clipboard returns exit 0 but has no implementation.
This commit is contained in:
parent
a55ad3f551
commit
e0c05dc72a
@ -34,18 +34,20 @@ docker compose up --build
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Modular MCPMixin architecture with 50 tools across 6 domain mixins:
|
Modular MCPMixin architecture with 65 tools across 8 domain mixins:
|
||||||
|
|
||||||
- **`src/server.py`**: FastMCP app and `ADBServer` class (thin orchestrator inheriting all mixins)
|
- **`src/server.py`**: FastMCP app and `ADBServer` class (thin orchestrator inheriting all mixins)
|
||||||
- **`src/config.py`**: Persistent singleton config (`~/.config/adb-mcp/config.json`)
|
- **`src/config.py`**: Persistent singleton config (`~/.config/adb-mcp/config.json`)
|
||||||
- **`src/models.py`**: Pydantic models (`DeviceInfo`, `CommandResult`, `ScreenshotResult`)
|
- **`src/models.py`**: Pydantic models (`DeviceInfo`, `CommandResult`, `ScreenshotResult`)
|
||||||
- **`src/mixins/base.py`**: Core ADB execution with `run_adb()`, `run_shell()`, and injection-safe `run_shell_args()` using `shlex.quote()`
|
- **`src/mixins/base.py`**: Core ADB execution with `run_adb()`, `run_shell()`, and injection-safe `run_shell_args()` using `shlex.quote()`
|
||||||
- **`src/mixins/devices.py`**: Device discovery, info, logcat, reboot
|
- **`src/mixins/devices.py`**: Device discovery, info, logcat, reboot
|
||||||
|
- **`src/mixins/connectivity.py`**: TCP/IP connect/disconnect, wireless pairing, device properties
|
||||||
- **`src/mixins/input.py`**: Tap, swipe, scroll, keys, text, clipboard, shell command
|
- **`src/mixins/input.py`**: Tap, swipe, scroll, keys, text, clipboard, shell command
|
||||||
- **`src/mixins/apps.py`**: Launch, close, install, intents, broadcasts
|
- **`src/mixins/apps.py`**: Launch, close, install, intents, broadcasts
|
||||||
- **`src/mixins/screenshot.py`**: Screen capture, recording, display settings
|
- **`src/mixins/screenshot.py`**: Screen capture, recording, display settings
|
||||||
- **`src/mixins/ui.py`**: Accessibility tree dump, element search, text polling
|
- **`src/mixins/ui.py`**: Accessibility tree dump, element search, text polling
|
||||||
- **`src/mixins/files.py`**: Push, pull, list, delete, exists
|
- **`src/mixins/files.py`**: Push, pull, list, delete, exists
|
||||||
|
- **`src/mixins/settings.py`**: System settings, radio toggles, brightness, notifications, clipboard read, media control
|
||||||
|
|
||||||
### Key Patterns
|
### Key Patterns
|
||||||
|
|
||||||
|
|||||||
38
README.md
38
README.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A [Model Context Protocol](https://modelcontextprotocol.io/) server that gives AI assistants direct control over Android devices through ADB. Point any MCP-compatible client at a phone plugged into USB, and it can take screenshots, tap buttons, launch apps, inspect UI elements, transfer files, and run shell commands — all through structured, type-safe tool calls.
|
A [Model Context Protocol](https://modelcontextprotocol.io/) server that gives AI assistants direct control over Android devices through ADB. Point any MCP-compatible client at a phone plugged into USB, and it can take screenshots, tap buttons, launch apps, inspect UI elements, transfer files, and run shell commands — all through structured, type-safe tool calls.
|
||||||
|
|
||||||
Built on [FastMCP](https://gofastmcp.com/) with a modular mixin architecture. 50 tools across 6 domains. Tested on real hardware.
|
Built on [FastMCP](https://gofastmcp.com/) with a modular mixin architecture. 65 tools across 8 domains. Tested on real hardware.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@ -57,6 +57,10 @@ claude mcp add mcadb -- uv run --directory /path/to/mcp-adb mcadb
|
|||||||
| | `devices_use` | Set active device for multi-device setups |
|
| | `devices_use` | Set active device for multi-device setups |
|
||||||
| | `devices_current` | Show which device is selected |
|
| | `devices_current` | Show which device is selected |
|
||||||
| | `device_info` | Battery, WiFi, storage, Android version, model |
|
| | `device_info` | Battery, WiFi, storage, Android version, model |
|
||||||
|
| **Connectivity** | `adb_connect` | Connect to device over TCP/IP |
|
||||||
|
| | `adb_disconnect` | Disconnect a network device |
|
||||||
|
| | `adb_pair` | Wireless debugging pairing (Android 11+) |
|
||||||
|
| | `device_properties` | Batch getprop (model, SoC, versions, serial, ABI) |
|
||||||
| **Input** | `input_tap` | Tap at screen coordinates |
|
| **Input** | `input_tap` | Tap at screen coordinates |
|
||||||
| | `input_swipe` | Swipe between two points |
|
| | `input_swipe` | Swipe between two points |
|
||||||
| | `input_scroll_down` | Scroll down (auto-detects screen size) |
|
| | `input_scroll_down` | Scroll down (auto-detects screen size) |
|
||||||
@ -80,6 +84,10 @@ claude mcp add mcadb -- uv run --directory /path/to/mcp-adb mcadb
|
|||||||
| | `wait_for_text` | Poll until text appears on screen |
|
| | `wait_for_text` | Poll until text appears on screen |
|
||||||
| | `wait_for_text_gone` | Poll until text disappears |
|
| | `wait_for_text_gone` | Poll until text disappears |
|
||||||
| | `tap_text` | Find an element by text and tap it |
|
| | `tap_text` | Find an element by text and tap it |
|
||||||
|
| **Settings** | `settings_get` | Read any system/global/secure setting |
|
||||||
|
| | `notification_list` | List recent notifications (title, text, package) |
|
||||||
|
| | `clipboard_get` | Read clipboard contents |
|
||||||
|
| | `media_control` | Play/pause/next/previous/stop/volume |
|
||||||
| **Config** | `config_status` | Show current settings |
|
| **Config** | `config_status` | Show current settings |
|
||||||
| | `config_set_developer_mode` | Toggle developer tools |
|
| | `config_set_developer_mode` | Toggle developer tools |
|
||||||
| | `config_set_screenshot_dir` | Set where screenshots are saved |
|
| | `config_set_screenshot_dir` | Set where screenshots are saved |
|
||||||
@ -109,6 +117,13 @@ Enable with `config_set_developer_mode(true)` to unlock power-user tools. Destru
|
|||||||
| | `file_list` | List directory contents |
|
| | `file_list` | List directory contents |
|
||||||
| | `file_delete` | Delete file (with confirmation) |
|
| | `file_delete` | Delete file (with confirmation) |
|
||||||
| | `file_exists` | Check if file exists |
|
| | `file_exists` | Check if file exists |
|
||||||
|
| **Connectivity** | `adb_tcpip` | Switch USB device to TCP/IP mode |
|
||||||
|
| **Settings** | `settings_put` | Write system/global/secure setting |
|
||||||
|
| | `wifi_toggle` | Enable/disable WiFi |
|
||||||
|
| | `bluetooth_toggle` | Enable/disable Bluetooth |
|
||||||
|
| | `airplane_mode_toggle` | Toggle airplane mode (with confirmation) |
|
||||||
|
| | `screen_brightness` | Set brightness 0-255 |
|
||||||
|
| | `screen_timeout` | Set screen timeout duration |
|
||||||
|
|
||||||
### Resources
|
### Resources
|
||||||
|
|
||||||
@ -146,6 +161,13 @@ Enable with `config_set_developer_mode(true)` to unlock power-user tools. Destru
|
|||||||
4. logcat_capture(filter_spec="MyApp:D *:S")
|
4. logcat_capture(filter_spec="MyApp:D *:S")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Connect to a WiFi device:**
|
||||||
|
```
|
||||||
|
1. adb_connect("10.20.0.25") → Connect to network device
|
||||||
|
2. devices_list() → Verify it appears
|
||||||
|
3. screenshot() → Capture from network device
|
||||||
|
```
|
||||||
|
|
||||||
**Multi-device workflow:**
|
**Multi-device workflow:**
|
||||||
```
|
```
|
||||||
1. devices_list() → See all connected devices
|
1. devices_list() → See all connected devices
|
||||||
@ -154,9 +176,17 @@ Enable with `config_set_developer_mode(true)` to unlock power-user tools. Destru
|
|||||||
4. screenshot() → Capture from selected device
|
4. screenshot() → Capture from selected device
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Read settings and control media:**
|
||||||
|
```
|
||||||
|
1. settings_get("global", "wifi_on") → Check WiFi state
|
||||||
|
2. notification_list() → See recent notifications
|
||||||
|
3. media_control("pause") → Pause media playback
|
||||||
|
4. clipboard_get() → Read clipboard contents
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The server uses FastMCP's [MCPMixin](https://gofastmcp.com/) pattern to organize 50 tools into focused, single-responsibility modules:
|
The server uses FastMCP's [MCPMixin](https://gofastmcp.com/) pattern to organize 65 tools into focused, single-responsibility modules:
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
@ -166,14 +196,16 @@ src/
|
|||||||
mixins/
|
mixins/
|
||||||
base.py ← ADB command execution, injection-safe shell quoting
|
base.py ← ADB command execution, injection-safe shell quoting
|
||||||
devices.py ← Device discovery, info, logcat, reboot
|
devices.py ← Device discovery, info, logcat, reboot
|
||||||
|
connectivity.py ← TCP/IP connect/disconnect, wireless pairing, properties
|
||||||
input.py ← Tap, swipe, scroll, keys, text, clipboard, shell
|
input.py ← Tap, swipe, scroll, keys, text, clipboard, shell
|
||||||
apps.py ← Launch, close, install, intents, broadcasts
|
apps.py ← Launch, close, install, intents, broadcasts
|
||||||
screenshot.py ← Capture, recording, display settings
|
screenshot.py ← Capture, recording, display settings
|
||||||
ui.py ← Accessibility tree, element search, text polling
|
ui.py ← Accessibility tree, element search, text polling
|
||||||
files.py ← Push, pull, list, delete, exists
|
files.py ← Push, pull, list, delete, exists
|
||||||
|
settings.py ← System settings, radios, brightness, notifications, media
|
||||||
```
|
```
|
||||||
|
|
||||||
`ADBServer` inherits all six mixins. Each mixin calls `run_shell_args()` (injection-safe) or `run_adb()` on the base class. The base handles device targeting, subprocess execution, and timeouts.
|
`ADBServer` inherits all eight mixins. Each mixin calls `run_shell_args()` (injection-safe) or `run_adb()` on the base class. The base handles device targeting, subprocess execution, and timeouts.
|
||||||
|
|
||||||
## Security Model
|
## Security Model
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcadb"
|
name = "mcadb"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
description = "Android ADB MCP Server for device automation via Model Context Protocol"
|
description = "Android ADB MCP Server for device automation via Model Context Protocol"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Ryan Malloy", email = "ryan@supported.systems"}
|
{name = "Ryan Malloy", email = "ryan@supported.systems"}
|
||||||
|
|||||||
@ -2,18 +2,22 @@
|
|||||||
|
|
||||||
from .apps import AppsMixin
|
from .apps import AppsMixin
|
||||||
from .base import ADBBaseMixin
|
from .base import ADBBaseMixin
|
||||||
|
from .connectivity import ConnectivityMixin
|
||||||
from .devices import DevicesMixin
|
from .devices import DevicesMixin
|
||||||
from .files import FilesMixin
|
from .files import FilesMixin
|
||||||
from .input import InputMixin
|
from .input import InputMixin
|
||||||
from .screenshot import ScreenshotMixin
|
from .screenshot import ScreenshotMixin
|
||||||
|
from .settings import SettingsMixin
|
||||||
from .ui import UIMixin
|
from .ui import UIMixin
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ADBBaseMixin",
|
"ADBBaseMixin",
|
||||||
"DevicesMixin",
|
"DevicesMixin",
|
||||||
|
"ConnectivityMixin",
|
||||||
"InputMixin",
|
"InputMixin",
|
||||||
"AppsMixin",
|
"AppsMixin",
|
||||||
"ScreenshotMixin",
|
"ScreenshotMixin",
|
||||||
"UIMixin",
|
"UIMixin",
|
||||||
"FilesMixin",
|
"FilesMixin",
|
||||||
|
"SettingsMixin",
|
||||||
]
|
]
|
||||||
|
|||||||
276
src/mixins/connectivity.py
Normal file
276
src/mixins/connectivity.py
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
"""Connectivity mixin for Android ADB MCP Server.
|
||||||
|
|
||||||
|
Provides tools for managing ADB network connections and device properties.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastmcp import Context
|
||||||
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
|
|
||||||
|
from ..config import is_developer_mode
|
||||||
|
from .base import ADBBaseMixin
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectivityMixin(ADBBaseMixin):
|
||||||
|
"""Mixin for ADB connectivity management.
|
||||||
|
|
||||||
|
Provides tools for:
|
||||||
|
- Connecting/disconnecting devices over TCP/IP
|
||||||
|
- Switching USB devices to network mode
|
||||||
|
- Wireless debugging pairing (Android 11+)
|
||||||
|
- Batch device property queries
|
||||||
|
"""
|
||||||
|
|
||||||
|
@mcp_tool()
|
||||||
|
async def adb_connect(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int = 5555,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Connect to a device over TCP/IP.
|
||||||
|
|
||||||
|
Establishes an ADB connection to a device on the network.
|
||||||
|
The device must already have ADB over TCP/IP enabled
|
||||||
|
(via adb_tcpip or developer options).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Device IP address or hostname
|
||||||
|
port: ADB port (default 5555)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Connection result with device address
|
||||||
|
"""
|
||||||
|
target = f"{host}:{port}"
|
||||||
|
result = await self.run_adb(["connect", target])
|
||||||
|
|
||||||
|
# ADB connect returns 0 even on failure — check stdout
|
||||||
|
connected = result.success and "connected" in result.stdout.lower()
|
||||||
|
already = "already connected" in result.stdout.lower()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": connected,
|
||||||
|
"already_connected": already,
|
||||||
|
"address": target,
|
||||||
|
"output": result.stdout,
|
||||||
|
"error": result.stderr if not connected else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool()
|
||||||
|
async def adb_disconnect(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int = 5555,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Disconnect a network-connected device.
|
||||||
|
|
||||||
|
Drops the ADB TCP/IP connection to the specified device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Device IP address or hostname
|
||||||
|
port: ADB port (default 5555)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Disconnection result
|
||||||
|
"""
|
||||||
|
target = f"{host}:{port}"
|
||||||
|
result = await self.run_adb(["disconnect", target])
|
||||||
|
|
||||||
|
disconnected = result.success and "disconnected" in result.stdout.lower()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": disconnected,
|
||||||
|
"address": target,
|
||||||
|
"output": result.stdout,
|
||||||
|
"error": result.stderr if not disconnected else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool(
|
||||||
|
tags={"developer"},
|
||||||
|
annotations={"requires": "developer_mode"},
|
||||||
|
)
|
||||||
|
async def adb_tcpip(
|
||||||
|
self,
|
||||||
|
ctx: Context,
|
||||||
|
port: int = 5555,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Switch a USB-connected device to TCP/IP mode.
|
||||||
|
|
||||||
|
[DEVELOPER MODE] Restarts ADB on the device in TCP/IP mode,
|
||||||
|
then fetches the device's IP address so you can immediately
|
||||||
|
call adb_connect() to reconnect over the network.
|
||||||
|
|
||||||
|
The device must be connected via USB. After switching, the
|
||||||
|
USB ADB connection will be dropped and you'll need to use
|
||||||
|
adb_connect(ip, port) to reconnect.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP context for logging
|
||||||
|
port: TCP port for ADB to listen on (default 5555)
|
||||||
|
device_id: Target USB device (required if multiple connected)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result with device IP address for subsequent adb_connect
|
||||||
|
"""
|
||||||
|
if not is_developer_mode():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Developer mode required",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reject if device_id looks like a network device (IP:port format)
|
||||||
|
target = device_id or self.get_current_device()
|
||||||
|
if target and re.match(r"\d+\.\d+\.\d+\.\d+:\d+", target):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"Device '{target}' is already a network device. "
|
||||||
|
"adb_tcpip only works on USB-connected devices."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get device IP before switching (wlan0)
|
||||||
|
ip_result = await self.run_shell_args(
|
||||||
|
["ip", "addr", "show", "wlan0"], device_id
|
||||||
|
)
|
||||||
|
device_ip = None
|
||||||
|
if ip_result.success:
|
||||||
|
match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/", ip_result.stdout)
|
||||||
|
if match:
|
||||||
|
device_ip = match.group(1)
|
||||||
|
|
||||||
|
if not device_ip:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
"Could not determine device IP address. "
|
||||||
|
"Ensure the device is connected to WiFi."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.info(f"Switching device to TCP/IP mode on port {port}...")
|
||||||
|
|
||||||
|
# Switch to TCP/IP mode — this is an ADB client command, not shell
|
||||||
|
result = await self.run_adb(["tcpip", str(port)], device_id)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.stderr or result.stdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.info(
|
||||||
|
f"Device switched to TCP/IP on port {port}. "
|
||||||
|
f"Connect with: adb_connect('{device_ip}', {port})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"port": port,
|
||||||
|
"device_ip": device_ip,
|
||||||
|
"connect_address": f"{device_ip}:{port}",
|
||||||
|
"message": (
|
||||||
|
f"Device now listening on {device_ip}:{port}. "
|
||||||
|
"USB connection will drop. Use adb_connect() to reconnect."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool()
|
||||||
|
async def adb_pair(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
pairing_code: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Pair with a device for wireless debugging (Android 11+).
|
||||||
|
|
||||||
|
Pairs with a device using the wireless debugging pairing code
|
||||||
|
shown in Developer Options > Wireless Debugging > Pair device.
|
||||||
|
|
||||||
|
After pairing, use adb_connect() with the connection address
|
||||||
|
(different from the pairing address) to establish the session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Device IP address from pairing dialog
|
||||||
|
port: Pairing port from pairing dialog
|
||||||
|
pairing_code: 6-digit pairing code from device screen
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Pairing result
|
||||||
|
"""
|
||||||
|
target = f"{host}:{port}"
|
||||||
|
result = await self.run_adb(["pair", target, pairing_code])
|
||||||
|
|
||||||
|
paired = result.success and "successfully paired" in result.stdout.lower()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": paired,
|
||||||
|
"address": target,
|
||||||
|
"output": result.stdout,
|
||||||
|
"error": result.stderr if not paired else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool()
|
||||||
|
async def device_properties(
|
||||||
|
self,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get detailed device properties via getprop.
|
||||||
|
|
||||||
|
Fetches a comprehensive batch of system properties including
|
||||||
|
hardware info, software versions, and device identifiers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: Target device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of device properties grouped by category
|
||||||
|
"""
|
||||||
|
props_to_fetch = {
|
||||||
|
"identity": [
|
||||||
|
("model", "ro.product.model"),
|
||||||
|
("manufacturer", "ro.product.manufacturer"),
|
||||||
|
("brand", "ro.product.brand"),
|
||||||
|
("device", "ro.product.device"),
|
||||||
|
("product", "ro.product.name"),
|
||||||
|
("serial", "ro.serialno"),
|
||||||
|
],
|
||||||
|
"software": [
|
||||||
|
("android_version", "ro.build.version.release"),
|
||||||
|
("sdk_version", "ro.build.version.sdk"),
|
||||||
|
("build_id", "ro.build.display.id"),
|
||||||
|
("security_patch", "ro.build.version.security_patch"),
|
||||||
|
("build_type", "ro.build.type"),
|
||||||
|
("build_fingerprint", "ro.build.fingerprint"),
|
||||||
|
],
|
||||||
|
"hardware": [
|
||||||
|
("chipset", "ro.board.platform"),
|
||||||
|
("cpu_abi", "ro.product.cpu.abi"),
|
||||||
|
("hardware", "ro.hardware"),
|
||||||
|
],
|
||||||
|
"system": [
|
||||||
|
("timezone", "persist.sys.timezone"),
|
||||||
|
("language", "persist.sys.language"),
|
||||||
|
("locale", "ro.product.locale"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result: dict[str, Any] = {"success": True}
|
||||||
|
|
||||||
|
for category, prop_list in props_to_fetch.items():
|
||||||
|
category_data: dict[str, str] = {}
|
||||||
|
for friendly_name, prop_key in prop_list:
|
||||||
|
value = await self.get_device_property(prop_key, device_id)
|
||||||
|
if value:
|
||||||
|
category_data[friendly_name] = value
|
||||||
|
if category_data:
|
||||||
|
result[category] = category_data
|
||||||
|
|
||||||
|
# Check if we got anything at all
|
||||||
|
if len(result) == 1: # only "success" key
|
||||||
|
result["success"] = False
|
||||||
|
result["error"] = "No properties returned. Is the device connected?"
|
||||||
|
|
||||||
|
return result
|
||||||
@ -474,10 +474,13 @@ class InputMixin(ADBBaseMixin):
|
|||||||
Success status
|
Success status
|
||||||
"""
|
"""
|
||||||
# Try cmd clipboard set (Android 12+, injection-safe via args)
|
# Try cmd clipboard set (Android 12+, injection-safe via args)
|
||||||
|
# Some devices return exit 0 but "No shell command implementation"
|
||||||
|
# in stderr — treat that as failure so the fallback triggers
|
||||||
result = await self.run_shell_args(["cmd", "clipboard", "set", text], device_id)
|
result = await self.run_shell_args(["cmd", "clipboard", "set", text], device_id)
|
||||||
|
cmd_worked = result.success and "no shell command" not in result.stderr.lower()
|
||||||
|
|
||||||
# Fallback: try am broadcast (Clipper app or similar)
|
# Fallback: try am broadcast (Clipper app or similar)
|
||||||
if not result.success:
|
if not cmd_worked:
|
||||||
result = await self.run_shell_args(
|
result = await self.run_shell_args(
|
||||||
[
|
[
|
||||||
"am",
|
"am",
|
||||||
@ -490,6 +493,21 @@ class InputMixin(ADBBaseMixin):
|
|||||||
],
|
],
|
||||||
device_id,
|
device_id,
|
||||||
)
|
)
|
||||||
|
# Broadcast returns exit 0 even with no receiver.
|
||||||
|
# Check for "result=-1" (receiver acknowledged) vs
|
||||||
|
# "result=0" (default, likely no receiver)
|
||||||
|
if result.success and "result=0" in result.stdout:
|
||||||
|
result = result.model_copy(
|
||||||
|
update={
|
||||||
|
"success": False,
|
||||||
|
"stderr": (
|
||||||
|
"Device does not support cmd clipboard "
|
||||||
|
"and no broadcast receiver found. Use "
|
||||||
|
"input_text() for simple text or install "
|
||||||
|
"a clipboard helper app."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
preview = text[:100] + "..." if len(text) > 100 else text
|
preview = text[:100] + "..." if len(text) > 100 else text
|
||||||
response: dict[str, Any] = {
|
response: dict[str, Any] = {
|
||||||
|
|||||||
701
src/mixins/settings.py
Normal file
701
src/mixins/settings.py
Normal file
@ -0,0 +1,701 @@
|
|||||||
|
"""Settings mixin for Android ADB MCP Server.
|
||||||
|
|
||||||
|
Provides tools for reading/writing system settings, toggling radios,
|
||||||
|
display configuration, notification access, clipboard, and media control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastmcp import Context
|
||||||
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
|
|
||||||
|
from ..config import is_developer_mode
|
||||||
|
from .base import ADBBaseMixin
|
||||||
|
|
||||||
|
_VALID_NAMESPACES = {"system", "global", "secure"}
|
||||||
|
_SETTING_KEY_PATTERN = re.compile(r"^[a-zA-Z0-9_.]+$")
|
||||||
|
|
||||||
|
_MEDIA_KEYCODES: dict[str, str] = {
|
||||||
|
"play": "KEYCODE_MEDIA_PLAY",
|
||||||
|
"pause": "KEYCODE_MEDIA_PAUSE",
|
||||||
|
"play_pause": "KEYCODE_MEDIA_PLAY_PAUSE",
|
||||||
|
"next": "KEYCODE_MEDIA_NEXT",
|
||||||
|
"previous": "KEYCODE_MEDIA_PREVIOUS",
|
||||||
|
"stop": "KEYCODE_MEDIA_STOP",
|
||||||
|
"mute": "KEYCODE_VOLUME_MUTE",
|
||||||
|
"volume_up": "KEYCODE_VOLUME_UP",
|
||||||
|
"volume_down": "KEYCODE_VOLUME_DOWN",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsMixin(ADBBaseMixin):
|
||||||
|
"""Mixin for Android system settings and controls.
|
||||||
|
|
||||||
|
Provides tools for:
|
||||||
|
- Reading/writing system, global, and secure settings
|
||||||
|
- WiFi, Bluetooth, and airplane mode toggles
|
||||||
|
- Display brightness and timeout
|
||||||
|
- Notification listing
|
||||||
|
- Clipboard reading
|
||||||
|
- Media playback control
|
||||||
|
"""
|
||||||
|
|
||||||
|
@mcp_tool()
|
||||||
|
async def settings_get(
|
||||||
|
self,
|
||||||
|
namespace: str,
|
||||||
|
key: str,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read an Android system setting.
|
||||||
|
|
||||||
|
Reads a value from the device's settings database.
|
||||||
|
Settings are organized into three namespaces:
|
||||||
|
- system: User-facing settings (brightness, font size, ringtone)
|
||||||
|
- global: Device-wide settings (wifi_on, airplane_mode_on, adb_enabled)
|
||||||
|
- secure: Security/privacy settings (location_mode, accessibility)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
namespace: Settings namespace — "system", "global", or "secure"
|
||||||
|
key: Setting key (e.g., "screen_brightness", "wifi_on")
|
||||||
|
device_id: Target device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The setting value
|
||||||
|
"""
|
||||||
|
if namespace not in _VALID_NAMESPACES:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"Invalid namespace '{namespace}'. "
|
||||||
|
f"Must be one of: {', '.join(sorted(_VALID_NAMESPACES))}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not _SETTING_KEY_PATTERN.match(key):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"Invalid key '{key}'. Keys must contain "
|
||||||
|
"only letters, digits, underscores, and dots."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self.run_shell_args(
|
||||||
|
["settings", "get", namespace, key], device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
value = result.stdout.strip()
|
||||||
|
is_null = value == "null"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"namespace": namespace,
|
||||||
|
"key": key,
|
||||||
|
"value": None if is_null else value,
|
||||||
|
"exists": not is_null,
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool(
|
||||||
|
tags={"developer"},
|
||||||
|
annotations={"requires": "developer_mode"},
|
||||||
|
)
|
||||||
|
async def settings_put(
|
||||||
|
self,
|
||||||
|
ctx: Context,
|
||||||
|
namespace: str,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Write an Android system setting.
|
||||||
|
|
||||||
|
[DEVELOPER MODE] Writes a value to the device's settings database.
|
||||||
|
Writing to the "secure" namespace requires additional confirmation
|
||||||
|
as it can affect security-sensitive behavior.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP context for elicitation/logging
|
||||||
|
namespace: Settings namespace — "system", "global", or "secure"
|
||||||
|
key: Setting key
|
||||||
|
value: Value to set
|
||||||
|
device_id: Target device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result with read-back verification
|
||||||
|
"""
|
||||||
|
if not is_developer_mode():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Developer mode required",
|
||||||
|
}
|
||||||
|
|
||||||
|
if namespace not in _VALID_NAMESPACES:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"Invalid namespace '{namespace}'. "
|
||||||
|
f"Must be one of: {', '.join(sorted(_VALID_NAMESPACES))}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not _SETTING_KEY_PATTERN.match(key):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"Invalid key '{key}'. Keys must contain "
|
||||||
|
"only letters, digits, underscores, and dots."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extra confirmation for secure namespace
|
||||||
|
if namespace == "secure":
|
||||||
|
confirmation = await ctx.elicit(
|
||||||
|
f"Writing to the 'secure' namespace can affect security-sensitive "
|
||||||
|
f"device behavior.\n\n"
|
||||||
|
f"Setting: {namespace}/{key} = {value}\n\n"
|
||||||
|
f"Proceed?",
|
||||||
|
["Yes, write setting", "Cancel"],
|
||||||
|
)
|
||||||
|
if confirmation.action != "accept" or confirmation.content == "Cancel":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"cancelled": True,
|
||||||
|
"message": "Settings write cancelled by user",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self.run_shell_args(
|
||||||
|
["settings", "put", namespace, key, value], device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read back to verify
|
||||||
|
verify = await self.run_shell_args(
|
||||||
|
["settings", "get", namespace, key], device_id
|
||||||
|
)
|
||||||
|
readback = verify.stdout.strip() if verify.success else None
|
||||||
|
|
||||||
|
await ctx.info(f"Set {namespace}/{key} = {value}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"namespace": namespace,
|
||||||
|
"key": key,
|
||||||
|
"value": value,
|
||||||
|
"readback": readback,
|
||||||
|
"verified": readback == value,
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool(
|
||||||
|
tags={"developer"},
|
||||||
|
annotations={"requires": "developer_mode"},
|
||||||
|
)
|
||||||
|
async def wifi_toggle(
|
||||||
|
self,
|
||||||
|
enabled: bool,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Toggle WiFi on or off.
|
||||||
|
|
||||||
|
[DEVELOPER MODE] Enables or disables WiFi using the svc command.
|
||||||
|
Verifies the state change after toggling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enabled: True to enable WiFi, False to disable
|
||||||
|
device_id: Target device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result with verified WiFi state
|
||||||
|
"""
|
||||||
|
if not is_developer_mode():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Developer mode required",
|
||||||
|
}
|
||||||
|
|
||||||
|
action = "enable" if enabled else "disable"
|
||||||
|
result = await self.run_shell_args(["svc", "wifi", action], device_id)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify state change
|
||||||
|
verify = await self.run_shell_args(
|
||||||
|
["settings", "get", "global", "wifi_on"], device_id
|
||||||
|
)
|
||||||
|
current = verify.stdout.strip() if verify.success else "unknown"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"action": action,
|
||||||
|
"wifi_on": current,
|
||||||
|
"verified": current == ("1" if enabled else "0"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool(
|
||||||
|
tags={"developer"},
|
||||||
|
annotations={"requires": "developer_mode"},
|
||||||
|
)
|
||||||
|
async def bluetooth_toggle(
|
||||||
|
self,
|
||||||
|
enabled: bool,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Toggle Bluetooth on or off.
|
||||||
|
|
||||||
|
[DEVELOPER MODE] Enables or disables Bluetooth using the svc command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enabled: True to enable Bluetooth, False to disable
|
||||||
|
device_id: Target device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result with action taken
|
||||||
|
"""
|
||||||
|
if not is_developer_mode():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Developer mode required",
|
||||||
|
}
|
||||||
|
|
||||||
|
action = "enable" if enabled else "disable"
|
||||||
|
result = await self.run_shell_args(["svc", "bluetooth", action], device_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": result.success,
|
||||||
|
"action": action,
|
||||||
|
"error": result.stderr if not result.success else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool(
|
||||||
|
tags={"developer"},
|
||||||
|
annotations={"requires": "developer_mode"},
|
||||||
|
)
|
||||||
|
async def airplane_mode_toggle(
|
||||||
|
self,
|
||||||
|
ctx: Context,
|
||||||
|
enabled: bool,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Toggle airplane mode on or off.
|
||||||
|
|
||||||
|
[DEVELOPER MODE] Enables or disables airplane mode.
|
||||||
|
Requires confirmation because enabling airplane mode on a
|
||||||
|
network-connected device will sever the ADB connection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP context for elicitation/logging
|
||||||
|
enabled: True to enable airplane mode, False to disable
|
||||||
|
device_id: Target device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result with airplane mode state
|
||||||
|
"""
|
||||||
|
if not is_developer_mode():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Developer mode required",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Warn about network disconnection risk
|
||||||
|
if enabled:
|
||||||
|
target = device_id or self.get_current_device()
|
||||||
|
is_network = target and re.match(r"\d+\.\d+\.\d+\.\d+:\d+", target)
|
||||||
|
|
||||||
|
warning = "Enable airplane mode?"
|
||||||
|
if is_network:
|
||||||
|
warning = (
|
||||||
|
"WARNING: Enabling airplane mode on a network-connected "
|
||||||
|
f"device ({target}) will sever the ADB connection. "
|
||||||
|
"You will not be able to reconnect until airplane mode "
|
||||||
|
"is manually disabled on the device.\n\nProceed?"
|
||||||
|
)
|
||||||
|
|
||||||
|
confirmation = await ctx.elicit(
|
||||||
|
warning,
|
||||||
|
["Yes, enable airplane mode", "Cancel"],
|
||||||
|
)
|
||||||
|
if confirmation.action != "accept" or confirmation.content == "Cancel":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"cancelled": True,
|
||||||
|
"message": "Airplane mode toggle cancelled by user",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set the setting
|
||||||
|
value = "1" if enabled else "0"
|
||||||
|
put_result = await self.run_shell_args(
|
||||||
|
["settings", "put", "global", "airplane_mode_on", value], device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not put_result.success:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": put_result.stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Broadcast the change so the system acts on it
|
||||||
|
await self.run_shell_args(
|
||||||
|
[
|
||||||
|
"am",
|
||||||
|
"broadcast",
|
||||||
|
"-a",
|
||||||
|
"android.intent.action.AIRPLANE_MODE",
|
||||||
|
"--ez",
|
||||||
|
"state",
|
||||||
|
str(enabled).lower(),
|
||||||
|
],
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
action = "enabled" if enabled else "disabled"
|
||||||
|
await ctx.info(f"Airplane mode {action}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"airplane_mode": enabled,
|
||||||
|
"action": action,
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool(
|
||||||
|
tags={"developer"},
|
||||||
|
annotations={"requires": "developer_mode"},
|
||||||
|
)
|
||||||
|
async def screen_brightness(
|
||||||
|
self,
|
||||||
|
level: int,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Set screen brightness level.
|
||||||
|
|
||||||
|
[DEVELOPER MODE] Sets the screen brightness to a specific level.
|
||||||
|
Automatically disables auto-brightness first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level: Brightness level 0-255 (0=minimum, 255=maximum)
|
||||||
|
device_id: Target device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result with brightness level set
|
||||||
|
"""
|
||||||
|
if not is_developer_mode():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Developer mode required",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not 0 <= level <= 255:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Brightness level must be 0-255, got {level}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Disable auto-brightness first
|
||||||
|
await self.run_shell_args(
|
||||||
|
["settings", "put", "system", "screen_brightness_mode", "0"],
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set brightness
|
||||||
|
result = await self.run_shell_args(
|
||||||
|
["settings", "put", "system", "screen_brightness", str(level)],
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": result.success,
|
||||||
|
"brightness": level,
|
||||||
|
"auto_brightness": False,
|
||||||
|
"error": result.stderr if not result.success else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool(
|
||||||
|
tags={"developer"},
|
||||||
|
annotations={"requires": "developer_mode"},
|
||||||
|
)
|
||||||
|
async def screen_timeout(
|
||||||
|
self,
|
||||||
|
seconds: int,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Set screen timeout duration.
|
||||||
|
|
||||||
|
[DEVELOPER MODE] Sets how long the screen stays on before
|
||||||
|
going to sleep. Capped at 1800 seconds (30 minutes).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seconds: Timeout in seconds (max 1800)
|
||||||
|
device_id: Target device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result with timeout value set
|
||||||
|
"""
|
||||||
|
if not is_developer_mode():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Developer mode required",
|
||||||
|
}
|
||||||
|
|
||||||
|
if seconds < 1 or seconds > 1800:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Timeout must be 1-1800 seconds, got {seconds}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Android stores timeout in milliseconds
|
||||||
|
ms = seconds * 1000
|
||||||
|
result = await self.run_shell_args(
|
||||||
|
["settings", "put", "system", "screen_off_timeout", str(ms)],
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": result.success,
|
||||||
|
"timeout_seconds": seconds,
|
||||||
|
"timeout_ms": ms,
|
||||||
|
"error": result.stderr if not result.success else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool()
|
||||||
|
async def notification_list(
|
||||||
|
self,
|
||||||
|
limit: int = 50,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List recent notifications.
|
||||||
|
|
||||||
|
Retrieves notifications from the notification shade.
|
||||||
|
Parses title, text, package, and post time from dumpsys output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of notifications to return (default 50)
|
||||||
|
device_id: Target device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of notifications with title, text, package, and time
|
||||||
|
"""
|
||||||
|
result = await self.run_shell_args(
|
||||||
|
["dumpsys", "notification", "--noredact"], device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications: list[dict[str, str | None]] = []
|
||||||
|
current: dict[str, str | None] = {}
|
||||||
|
|
||||||
|
for line in result.stdout.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# Look for notification record boundaries
|
||||||
|
if "NotificationRecord" in stripped:
|
||||||
|
if current:
|
||||||
|
notifications.append(current)
|
||||||
|
if len(notifications) >= limit:
|
||||||
|
break
|
||||||
|
current = {}
|
||||||
|
# Extract package from NotificationRecord line
|
||||||
|
pkg_match = re.search(r"pkg=(\S+)", stripped)
|
||||||
|
if pkg_match:
|
||||||
|
current["package"] = pkg_match.group(1)
|
||||||
|
|
||||||
|
# Extract fields from extras or notification content
|
||||||
|
if "android.title=" in stripped:
|
||||||
|
title_match = re.search(r"android\.title=(.+?)(?:\s*$)", stripped)
|
||||||
|
if title_match:
|
||||||
|
current["title"] = title_match.group(1).strip()
|
||||||
|
|
||||||
|
if "android.text=" in stripped:
|
||||||
|
text_match = re.search(r"android\.text=(.+?)(?:\s*$)", stripped)
|
||||||
|
if text_match:
|
||||||
|
current["text"] = text_match.group(1).strip()
|
||||||
|
|
||||||
|
if "postTime=" in stripped:
|
||||||
|
time_match = re.search(r"postTime=(\d+)", stripped)
|
||||||
|
if time_match:
|
||||||
|
current["post_time"] = time_match.group(1)
|
||||||
|
|
||||||
|
# Don't forget the last one
|
||||||
|
if current and len(notifications) < limit:
|
||||||
|
notifications.append(current)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"notifications": notifications,
|
||||||
|
"count": len(notifications),
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp_tool()
|
||||||
|
async def clipboard_get(
|
||||||
|
self,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read the device clipboard contents.
|
||||||
|
|
||||||
|
Retrieves the current text from the device clipboard.
|
||||||
|
Uses service call to read the primary clip via Binder IPC,
|
||||||
|
which works across Android versions without requiring
|
||||||
|
a specific shell command implementation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: Target device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Clipboard text content
|
||||||
|
"""
|
||||||
|
# getPrimaryClip via Binder (transaction 4 on Android 12+)
|
||||||
|
result = await self.run_shell_args(
|
||||||
|
[
|
||||||
|
"service",
|
||||||
|
"call",
|
||||||
|
"clipboard",
|
||||||
|
"4",
|
||||||
|
"s16",
|
||||||
|
"com.android.shell",
|
||||||
|
],
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.success and "Parcel(" in result.stdout:
|
||||||
|
text = self._parse_clipboard_parcel(result.stdout)
|
||||||
|
if text is not None:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"text": text,
|
||||||
|
"method": "service_call",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
"Could not read clipboard. The device may have "
|
||||||
|
"an empty clipboard or use an unsupported format."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_clipboard_parcel(raw: str) -> str | None:
|
||||||
|
"""Parse clipboard text from service call parcel output.
|
||||||
|
|
||||||
|
The Parcel contains a ClipData object with:
|
||||||
|
- Status word, non-null marker
|
||||||
|
- ClipDescription (label, MIME types in UTF-16LE)
|
||||||
|
- Extras, timestamps, flags
|
||||||
|
- Item count, then for each item: CharSequence as
|
||||||
|
a type marker + length-prefixed UTF-8 text
|
||||||
|
|
||||||
|
We scan for length-prefixed UTF-8 text blocks after
|
||||||
|
the MIME type section and return the longest valid one.
|
||||||
|
"""
|
||||||
|
# Extract hex words from parcel dump
|
||||||
|
hex_words = re.findall(r"\b([0-9a-f]{8})\b", raw)
|
||||||
|
if not hex_words:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Convert to bytes (little-endian 32-bit words)
|
||||||
|
data = b"".join(int(w, 16).to_bytes(4, "little") for w in hex_words)
|
||||||
|
|
||||||
|
# Check status (first 4 bytes should be 0 for success)
|
||||||
|
if len(data) < 8:
|
||||||
|
return None
|
||||||
|
status = int.from_bytes(data[0:4], "little")
|
||||||
|
if status != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find "text/plain" in UTF-16LE to locate the MIME section
|
||||||
|
mime_marker = "text/plain".encode("utf-16-le")
|
||||||
|
mime_pos = data.find(mime_marker)
|
||||||
|
if mime_pos == -1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Scan from after MIME section for length-prefixed UTF-8
|
||||||
|
# text blocks. Return the longest valid one found.
|
||||||
|
search_start = mime_pos + len(mime_marker)
|
||||||
|
best_text: str | None = None
|
||||||
|
best_len = 0
|
||||||
|
|
||||||
|
pos = search_start
|
||||||
|
while pos < len(data) - 8:
|
||||||
|
text_len = int.from_bytes(data[pos : pos + 4], "little")
|
||||||
|
if 2 < text_len < 100000 and pos + 4 + text_len <= len(data):
|
||||||
|
text_bytes = data[pos + 4 : pos + 4 + text_len]
|
||||||
|
try:
|
||||||
|
text = text_bytes.decode("utf-8")
|
||||||
|
# Verify mostly printable (not binary garbage)
|
||||||
|
printable = sum(1 for c in text if c.isprintable() or c in "\n\r\t")
|
||||||
|
ratio = printable / max(len(text), 1)
|
||||||
|
if ratio > 0.8 and text_len > best_len:
|
||||||
|
best_text = text
|
||||||
|
best_len = text_len
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass
|
||||||
|
pos += 4
|
||||||
|
|
||||||
|
return best_text
|
||||||
|
|
||||||
|
@mcp_tool()
|
||||||
|
async def media_control(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Control media playback.
|
||||||
|
|
||||||
|
Sends media key events to control the active media player.
|
||||||
|
|
||||||
|
Available actions:
|
||||||
|
- play: Start playback
|
||||||
|
- pause: Pause playback
|
||||||
|
- play_pause: Toggle play/pause
|
||||||
|
- next: Skip to next track
|
||||||
|
- previous: Go to previous track
|
||||||
|
- stop: Stop playback
|
||||||
|
- mute: Toggle mute
|
||||||
|
- volume_up: Increase volume
|
||||||
|
- volume_down: Decrease volume
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: Media action (play, pause, next, previous, stop, etc.)
|
||||||
|
device_id: Target device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status with action performed
|
||||||
|
"""
|
||||||
|
action_lower = action.lower().strip()
|
||||||
|
keycode = _MEDIA_KEYCODES.get(action_lower)
|
||||||
|
|
||||||
|
if not keycode:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"Unknown action '{action}'. "
|
||||||
|
f"Available: {', '.join(sorted(_MEDIA_KEYCODES))}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self.run_shell_args(["input", "keyevent", keycode], device_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": result.success,
|
||||||
|
"action": action_lower,
|
||||||
|
"keycode": keycode,
|
||||||
|
"error": result.stderr if not result.success else None,
|
||||||
|
}
|
||||||
@ -21,26 +21,37 @@ from fastmcp.contrib.mcp_mixin import mcp_resource, mcp_tool
|
|||||||
from .config import get_config
|
from .config import get_config
|
||||||
from .mixins import (
|
from .mixins import (
|
||||||
AppsMixin,
|
AppsMixin,
|
||||||
|
ConnectivityMixin,
|
||||||
DevicesMixin,
|
DevicesMixin,
|
||||||
FilesMixin,
|
FilesMixin,
|
||||||
InputMixin,
|
InputMixin,
|
||||||
ScreenshotMixin,
|
ScreenshotMixin,
|
||||||
|
SettingsMixin,
|
||||||
UIMixin,
|
UIMixin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ADBServer(
|
class ADBServer(
|
||||||
DevicesMixin, InputMixin, AppsMixin, ScreenshotMixin, UIMixin, FilesMixin
|
DevicesMixin,
|
||||||
|
ConnectivityMixin,
|
||||||
|
InputMixin,
|
||||||
|
AppsMixin,
|
||||||
|
ScreenshotMixin,
|
||||||
|
UIMixin,
|
||||||
|
FilesMixin,
|
||||||
|
SettingsMixin,
|
||||||
):
|
):
|
||||||
"""Android ADB MCP Server combining all functionality.
|
"""Android ADB MCP Server combining all functionality.
|
||||||
|
|
||||||
Inherits from mixins:
|
Inherits from mixins:
|
||||||
- DevicesMixin: Device listing, selection, info, logcat
|
- DevicesMixin: Device listing, selection, info, logcat
|
||||||
|
- ConnectivityMixin: TCP/IP connect/disconnect, pairing, device properties
|
||||||
- InputMixin: Tap, swipe, keys, text input, clipboard
|
- InputMixin: Tap, swipe, keys, text input, clipboard
|
||||||
- AppsMixin: App launching, URL opening, package management, intents
|
- AppsMixin: App launching, URL opening, package management, intents
|
||||||
- ScreenshotMixin: Screen capture, recording, display control
|
- ScreenshotMixin: Screen capture, recording, display control
|
||||||
- UIMixin: UI hierarchy inspection, element finding, text waiting
|
- UIMixin: UI hierarchy inspection, element finding, text waiting
|
||||||
- FilesMixin: File push/pull between host and device
|
- FilesMixin: File push/pull between host and device
|
||||||
|
- SettingsMixin: System settings, radios, brightness, notifications, media
|
||||||
|
|
||||||
Developer mode enables additional tools for power users.
|
Developer mode enables additional tools for power users.
|
||||||
"""
|
"""
|
||||||
@ -131,6 +142,12 @@ class ADBServer(
|
|||||||
"devices_current - Get current device info",
|
"devices_current - Get current device info",
|
||||||
"device_info - Battery, wifi, storage, system info",
|
"device_info - Battery, wifi, storage, system info",
|
||||||
],
|
],
|
||||||
|
"connectivity": [
|
||||||
|
"adb_connect - Connect to device over TCP/IP",
|
||||||
|
"adb_disconnect - Disconnect network device",
|
||||||
|
"adb_pair - Wireless debugging pairing (Android 11+)",
|
||||||
|
"device_properties - Batch getprop (model, SoC, versions)",
|
||||||
|
],
|
||||||
"input": [
|
"input": [
|
||||||
"input_tap - Tap at coordinates",
|
"input_tap - Tap at coordinates",
|
||||||
"input_swipe - Swipe between points",
|
"input_swipe - Swipe between points",
|
||||||
@ -158,6 +175,12 @@ class ADBServer(
|
|||||||
"wait_for_text_gone - Wait for text to disappear",
|
"wait_for_text_gone - Wait for text to disappear",
|
||||||
"tap_text - Find element by text and tap it",
|
"tap_text - Find element by text and tap it",
|
||||||
],
|
],
|
||||||
|
"settings": [
|
||||||
|
"settings_get - Read any system/global/secure setting",
|
||||||
|
"notification_list - List recent notifications",
|
||||||
|
"clipboard_get - Read clipboard contents",
|
||||||
|
"media_control - Play/pause/next/previous/stop/volume",
|
||||||
|
],
|
||||||
"config": [
|
"config": [
|
||||||
"config_status - Show current settings",
|
"config_status - Show current settings",
|
||||||
"config_set_developer_mode - Toggle developer tools",
|
"config_set_developer_mode - Toggle developer tools",
|
||||||
@ -180,6 +203,13 @@ class ADBServer(
|
|||||||
"logcat_capture / logcat_clear - Android logs",
|
"logcat_capture / logcat_clear - Android logs",
|
||||||
"file_push / file_pull - Transfer files",
|
"file_push / file_pull - Transfer files",
|
||||||
"file_list / file_delete / file_exists - File operations",
|
"file_list / file_delete / file_exists - File operations",
|
||||||
|
"adb_tcpip - Switch USB device to TCP/IP mode",
|
||||||
|
"settings_put - Write system/global/secure setting",
|
||||||
|
"wifi_toggle - Enable/disable WiFi",
|
||||||
|
"bluetooth_toggle - Enable/disable Bluetooth",
|
||||||
|
"airplane_mode_toggle - Toggle airplane mode",
|
||||||
|
"screen_brightness - Set brightness 0-255",
|
||||||
|
"screen_timeout - Set screen timeout",
|
||||||
]
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -202,12 +232,18 @@ mcp = FastMCP(
|
|||||||
Use devices_list() first to see connected devices.
|
Use devices_list() first to see connected devices.
|
||||||
If multiple devices are connected, use devices_use(device_id) to select one.
|
If multiple devices are connected, use devices_use(device_id) to select one.
|
||||||
|
|
||||||
|
For network devices, use adb_connect(host, port) to establish a connection.
|
||||||
|
Use adb_pair() for wireless debugging on Android 11+.
|
||||||
|
|
||||||
Common workflows:
|
Common workflows:
|
||||||
1. Take screenshot: screenshot()
|
1. Take screenshot: screenshot()
|
||||||
2. Tap on screen: input_tap(x, y)
|
2. Tap on screen: input_tap(x, y)
|
||||||
3. Launch app: app_launch("com.android.chrome")
|
3. Launch app: app_launch("com.android.chrome")
|
||||||
4. Open URL: app_open_url("https://example.com")
|
4. Open URL: app_open_url("https://example.com")
|
||||||
5. Navigate back: input_back()
|
5. Navigate back: input_back()
|
||||||
|
6. Connect WiFi device: adb_connect("10.20.0.25")
|
||||||
|
7. Read a setting: settings_get("global", "wifi_on")
|
||||||
|
8. Control media: media_control("pause")
|
||||||
|
|
||||||
Enable developer mode for advanced tools:
|
Enable developer mode for advanced tools:
|
||||||
config_set_developer_mode(True)
|
config_set_developer_mode(True)
|
||||||
@ -226,7 +262,7 @@ def main():
|
|||||||
|
|
||||||
package_version = version("mcadb")
|
package_version = version("mcadb")
|
||||||
except Exception:
|
except Exception:
|
||||||
package_version = "0.3.1"
|
package_version = "0.4.0"
|
||||||
|
|
||||||
print(f"📱 mcadb v{package_version}", flush=True)
|
print(f"📱 mcadb v{package_version}", flush=True)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user