A Model Context Protocol (MCP) server for managing multiple interactive Android shells across multiple devices. Designed specifically for AI agent interaction with robust hang detection and AI-driven decision making.
- Multi-device support - Manage shells on multiple ADB/fastboot devices simultaneously
- Root & non-root shells - Persistent interactive sessions with
suescalation - AI-centric design - Returns
STATUS: UNCERTAINfor ambiguous situations, letting the AI decide - Hang prevention - Smart detection with false-positive protection for slow commands
- Background jobs - Run long commands without blocking
- 9 consolidated tools - Optimized for LLM context efficiency
- Batch command execution - Run multiple commands in a single call
-
Install
uv(recommended Python package manager):# macOS/Linux curl -LsSf https://astral.sh/uv/install.sh | sh # Windows powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
-
Install Python 3.10+:
uv python install 3.10
-
Android SDK Platform Tools (
adbandfastbootin PATH):# Verify installation adb --version fastboot --version
cd android-root
uv sync # or: pip install -r requirements.txtAdd this server to your MCP client configuration. The JSON format is the same across all clients.
Config file location:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
{
"mcpServers": {
"android-shell": {
"command": "uv",
"args": [
"run",
"--directory",
"/path/to/android-root",
"python",
"server.py"
],
"env": {
"FASTMCP_LOG_LEVEL": "ERROR"
}
}
}
}Config file: ~/.codeium/windsurf/mcp_config.json
Or navigate to: Windsurf Settings → Advanced Settings → MCP Servers → Add custom server
{
"mcpServers": {
"android-shell": {
"command": "uv",
"args": [
"run",
"--directory",
"/path/to/android-root",
"python",
"server.py"
],
"env": {
"FASTMCP_LOG_LEVEL": "ERROR"
}
}
}
}Config file locations:
- Global (all projects):
~/.cursor/mcp.json - Project-specific:
.cursor/mcp.jsonin your project root
{
"mcpServers": {
"android-shell": {
"command": "uv",
"args": [
"run",
"--directory",
"/path/to/android-root",
"python",
"server.py"
],
"env": {
"FASTMCP_LOG_LEVEL": "ERROR"
}
}
}
}Config file: .vscode/mcp.json in your workspace
{
"mcpServers": {
"android-shell": {
"command": "uv",
"args": [
"run",
"--directory",
"/path/to/android-root",
"python",
"server.py"
],
"env": {
"FASTMCP_LOG_LEVEL": "ERROR"
}
}
}
}If you publish this package to PyPI, users can use uvx for automatic installation:
{
"mcpServers": {
"android-shell": {
"command": "uvx",
"args": ["android-shell-mcp@latest"],
"env": {
"FASTMCP_LOG_LEVEL": "ERROR"
}
}
}
}{
"mcpServers": {
"android-shell": {
"command": "python",
"args": ["/path/to/android-root/server.py"],
"env": {
"FASTMCP_LOG_LEVEL": "ERROR"
}
}
}
}Note: After adding the configuration, restart your MCP client (Claude Desktop, Windsurf, Cursor, etc.) for changes to take effect.
After configuration, test that the server is working:
- In Claude/Cursor/Windsurf: Ask the AI to run
list_devices() - Manual test:
cd android-root uv run python server.py # Server should start without errors
# 1. List connected devices
list_devices()
# 2. Start a root shell on a device
start_shell("DEVICE_SERIAL", "root") # Returns shell_id
# 3. Run a single command
run_command(shell_id, "ls /data")
# 4. Run multiple commands efficiently (RECOMMENDED)
run_commands(shell_id, ["ls /data", "whoami", "cat /proc/version"])
# 5. Clean up
stop_shell() # Stop all shellsThis server provides 9 consolidated tools optimized for LLM context efficiency.
List all connected Android devices in ADB and fastboot modes.
list_devices()
# Returns: Device serials, modes (adb/fastboot/recovery), model namesStart a new interactive shell on a specific device.
start_shell(device_serial, shell_type="root")
# shell_type: "root" or "non_root"
# Returns: shell_id for use in other commandsStop shell(s). Pass shell_id to stop one, or omit to stop ALL shells.
stop_shell(shell_id) # Stop specific shell
stop_shell() # Stop ALL shells (cleanup)Get shell status. Pass shell_id for one shell, or omit to list all.
shell_status(shell_id) # Detailed status for one shell
shell_status() # List all active shellsExecute a single command in a shell.
run_command(
shell_id,
command,
timeout_seconds=30,
working_directory=None,
max_lines=None, # Limit output (protects context window!)
grep=None # Filter output to matching lines
)Run multiple commands in ONE call. Use this for batch operations!
run_commands(
shell_id,
commands=["ls /data", "cat file.txt", "ps aux"],
stop_on_error=False,
max_lines_per_command=50,
grep=None
)
# Returns: Summary (X/Y succeeded) + results for each commandManage background jobs: start, check, or list.
background_job("start", shell_id=id, command="long_task.sh") # Returns job_id
background_job("check", job_id=job_id) # Check status
background_job("list") # List all jobsTransfer files to/from device.
file_transfer("pull", device_serial, "/data/local/tmp/log.txt")
file_transfer("push", device_serial, "/data/local/tmp/config.txt", content="key=value")Interact with a shell: peek at output, send input, or send control characters.
shell_interact("peek", shell_id) # See current output
shell_interact("input", shell_id, text="y") # Answer prompt
shell_interact("control", shell_id, char="c") # Send Ctrl+C
shell_interact("diagnose", shell_id) # Full diagnosisThe server returns structured status messages:
| Status | Meaning | AI Action |
|---|---|---|
SUCCESS |
Command completed successfully | Continue |
COMMAND_FAILED |
Command ran but returned non-zero | Check output |
TIMEOUT |
Hard timeout reached | Increase timeout or investigate |
WAITING_FOR_INPUT |
Detected interactive prompt | Use send_input() to respond |
UNCERTAIN |
Ambiguous situation | Use inspection tools to decide |
ERROR |
Something went wrong | Read error message |
This is the AI-centric approach. When the server can't deterministically decide if a command is stuck, it returns:
STATUS: UNCERTAIN
Shell: abc123_root_xxxx
Command: some_command
Elapsed: 8.5s
No output for: 6.2s
The command has produced no output recently. This could mean:
1. It's working on something slow (downloading, processing)
2. It's waiting for input that wasn't detected
3. It's genuinely stuck
WHAT YOU (the AI) SHOULD DO:
• Use peek_output('abc123_root_xxxx') to check for new output
• Use diagnose_shell('abc123_root_xxxx') for detailed analysis
• If you think it's stuck: send_control_char('abc123_root_xxxx', 'c')
• If it needs input: send_input('abc123_root_xxxx', 'your_response')
The AI then uses its reasoning to decide the appropriate action.
The server automatically detects:
- Interactive prompts (
[y/n],Password:, etc.) - Dangerous commands (
vim,top,catwithout args) - Stuck processes (no output for extended period)
Commands known to be slow/silent get special treatment:
wget,curl,rsync- Network operationscp,dd,tar- File operationsfind,grep- Search operationsmake,gradle- Build operations- Any command with
--quiet,-s,>/dev/null
These get 10x longer tolerance before being flagged.
When recovery is needed:
- Ctrl+C (SIGINT)
- Ctrl+D (EOF)
- Ctrl+Z + kill (background and terminate)
android-root/
├── server.py # MCP entry point
├── __init__.py # Package exports
├── core/ # Core business logic
│ ├── __init__.py
│ ├── models.py # Data classes and enums
│ ├── config.py # Constants and patterns
│ ├── shell.py # Shell class - single session management
│ └── manager.py # ShellManager - multi-device orchestration
├── tools/ # MCP tools
│ ├── __init__.py
│ └── handlers.py # MCP tool definitions (9 tools)
├── utils/ # Utilities
│ ├── __init__.py
│ └── analytics.py # Usage analytics
├── tests/ # Tests
│ ├── __init__.py
│ └── test_main.py
├── pyproject.toml
├── requirements.txt
└── uv.lock
Shell(core/shell.py) - Single interactive pexpect session with hang detectionShellManager(core/manager.py) - Manages multiple shells across multiple devicesBackgroundJob(core/models.py) - Tracks background command execution
In core/config.py:
# Timing
PROGRESS_CHECK_INTERVAL = 0.5 # How often to check for output
STUCK_THRESHOLD_INTERVALS = 4 # Intervals before considering stuck (2s)
MIN_TIME_BEFORE_STUCK_CHECK = 5.0 # Min elapsed time before stuck check
SLOW_COMMAND_TIMEOUT_MULTIPLIER = 10 # 10x tolerance for slow commands
# Patterns
INTERACTIVE_PROMPT_PATTERNS = [...] # 18 patterns for detecting prompts
DANGEROUS_COMMANDS = [...] # Commands that may hang
SLOW_SILENT_COMMANDS = [...] # Commands that are legitimately slow# 1. Check devices
list_devices()
# → STATUS: FOUND_2_DEVICE(S)
# RFXXXX1234: ADB (SM-G990U)
# RFXXXX5678: ADB (Pixel_6)
# 2. Start shell
start_shell("RFXXXX1234", "root")
# → STATUS: CONNECTED
# Shell: RFXXXX12_root_a1b2
# 3. Run single command
run_command("RFXXXX12_root_a1b2", "ls /data/data | head -5")
# → STATUS: SUCCESS
# EXIT_CODE: 0
# OUTPUT: ...
# 4. Run multiple commands efficiently
run_commands("RFXXXX12_root_a1b2", [
"whoami",
"cat /proc/version",
"ls /data/app | wc -l"
])
# → 3/3 succeeded, 0 failed
# [1] SUCCESS: whoami
# root
# [2] SUCCESS: cat /proc/version
# Linux version 5.10...
# [3] SUCCESS: ls /data/app | wc -l
# 42# Command asks for confirmation
run_command(shell_id, "rm -i /sdcard/test.txt")
# → STATUS: WAITING_FOR_INPUT
# Detected: [y/n] prompt
# AI responds using shell_interact
shell_interact("input", shell_id, text="y")
# → STATUS: SENT
# Check result
shell_interact("peek", shell_id)
# → removed '/sdcard/test.txt'# Long-running command returns uncertain
run_command(shell_id, "find / -name '*.apk'", timeout_seconds=60)
# → STATUS: UNCERTAIN
# No output for 6.2s
# AI investigates
shell_interact("peek", shell_id)
# → (shows recent find output - still working!)
# Full diagnosis
shell_interact("diagnose", shell_id)
# → SHELL DIAGNOSIS
# Responsive: True
# SUGGESTED ACTIONS: ...# Pull a file from device
file_transfer("pull", "RFXXXX1234", "/data/local/tmp/log.txt")
# → STATUS: SUCCESS
# CONTENT: <file contents>
# Push content to device
file_transfer("push", "RFXXXX1234", "/data/local/tmp/config.ini",
content="[settings]\ndebug=true")
# → STATUS: SUCCESS# Start long-running task
background_job("start", shell_id="RFXXXX12_root_a1b2",
command="find / -name '*.log' > /tmp/logs.txt")
# → JOB_ID: job_abc123
# Check status later
background_job("check", job_id="job_abc123")
# → STATUS: RUNNING (or COMPLETED)
# OUTPUT: ...
# List all jobs
background_job("list")
# → 2 background jobs...- The server maintains persistent
susessions - Commands are sent directly to the root shell, NOT wrapped with
adb shell su -c - This allows interactive root sessions
- Android shells have various prompt formats depending on ROM
- Multiple regex patterns are used:
$,#,user@host:path$ - If prompt detection fails, try
stop_shell+start_shell
codec_errors="replace"is used to handle binary output- Commands producing binary data won't corrupt the shell
- Output is written to
/data/local/tmp/{job_id}.out - Jobs persist even if shell is closed
- Clean up with
rm /data/local/tmp/job_*.out
shell_interact("diagnose", shell_id) # Get full diagnosis
shell_interact("control", shell_id, char="c") # Try Ctrl+C
# If still stuck:
stop_shell(shell_id)
start_shell(device_serial, "root")Check on the device:
- Is Magisk/SuperSU installed?
- Is the app granted root access?
- Is there a pending "Allow" dialog on screen?
# Check ADB connection
adb devices -l
# Restart ADB if needed
adb kill-server
adb start-server- Follow the existing code style
- Add team comments:
# TEAM_XXX: description - Update
core/config.pyfor new patterns - Test with real devices when possible
- Create team files in
.teams/for documentation
MIT
- MCP Protocol: modelcontextprotocol.io
- uv Package Manager: astral.sh/uv
- Android Platform Tools: developer.android.com