#
tokens: 12217/50000 11/11 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── .python-version
├── adbdevicemanager.py
├── config.yaml.example
├── LICENSE
├── pyproject.toml
├── README.md
├── run_tests.py
├── server.py
├── tests
│   ├── __init__.py
│   ├── test_adb_device_manager.py
│   ├── test_config.py
│   └── test_integration.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------

```
1 | 3.11
2 | 
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Python-generated files
 2 | __pycache__/
 3 | *.py[oc]
 4 | build/
 5 | dist/
 6 | wheels/
 7 | *.egg-info
 8 | 
 9 | # Virtual environments
10 | .venv
11 | 
12 | # Project specific files
13 | *.png
14 | window_dump.xml
15 | output.mov
16 | config.yaml
17 | test.py
18 | .coverage
19 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Android MCP Server
  2 | 
  3 | An MCP (Model Context Protocol) server that provides programmatic control over
  4 | Android devices through ADB (Android Debug Bridge). This server exposes
  5 | various Android device management capabilities that can be accessed by MCP
  6 | clients like [Claude desktop](https://modelcontextprotocol.io/quickstart/user)
  7 | and Code editors
  8 | (e.g. [Cursor](https://docs.cursor.com/context/model-context-protocol))
  9 | 
 10 | ## Features
 11 | 
 12 | - 🔧 ADB Command Execution
 13 | - 📸 Device Screenshot Capture
 14 | - 🎯 UI Layout Analysis
 15 | - 📱 Device Package Management
 16 | 
 17 | ## Prerequisites
 18 | 
 19 | - Python 3.x
 20 | - ADB (Android Debug Bridge) installed and configured
 21 | - Android device or emulator (not tested)
 22 | 
 23 | ## Installation
 24 | 
 25 | 1. Clone the repository:
 26 | 
 27 | ```bash
 28 | git clone https://github.com/minhalvp/android-mcp-server.git
 29 | cd android-mcp-server
 30 | ```
 31 | 
 32 | 2. Install dependencies:
 33 | This project uses [uv](https://github.com/astral-sh/uv) for project
 34 | management via various methods of
 35 | [installation](https://docs.astral.sh/uv/getting-started/installation/).
 36 | 
 37 | ```bash
 38 | uv python install 3.11
 39 | uv sync
 40 | ```
 41 | 
 42 | ## Configuration
 43 | 
 44 | The server supports flexible device configuration with multiple usage scenarios.
 45 | 
 46 | ### Device Selection Modes
 47 | 
 48 | **1. Automatic Selection (Recommended for single device)**
 49 | 
 50 | - No configuration file needed
 51 | - Automatically connects to the only connected device
 52 | - Perfect for development with a single test device
 53 | 
 54 | **2. Manual Device Selection**
 55 | 
 56 | - Use when you have multiple devices connected
 57 | - Specify exact device in configuration file
 58 | 
 59 | ### Configuration File (Optional)
 60 | 
 61 | The configuration file (`config.yaml`) is **optional**. If not present, the server will automatically select the device if only one is connected.
 62 | 
 63 | #### For Automatic Selection
 64 | 
 65 | Simply ensure only one device is connected and run the server - no configuration needed!
 66 | 
 67 | #### For Manual Selection
 68 | 
 69 | 1. Create a configuration file:
 70 | 
 71 | ```bash
 72 | cp config.yaml.example config.yaml
 73 | ```
 74 | 
 75 | 2. Edit `config.yaml` and specify your device:
 76 | 
 77 | ```yaml
 78 | device:
 79 |   name: "your-device-serial-here" # Device identifier from 'adb devices'
 80 | ```
 81 | 
 82 | **For auto-selection**, you can use any of these methods:
 83 | 
 84 | ```yaml
 85 | device:
 86 |   name: null              # Explicit null (recommended)
 87 |   # name: ""              # Empty string  
 88 |   # name:                 # Or leave empty/comment out
 89 | ```
 90 | 
 91 | ### Finding Your Device Serial
 92 | 
 93 | To find your device identifier, run:
 94 | 
 95 | ```bash
 96 | adb devices
 97 | ```
 98 | 
 99 | Example output:
100 | 
101 | ```
102 | List of devices attached
103 | 13b22d7f        device
104 | emulator-5554   device
105 | ```
106 | 
107 | Use the first column value (e.g., `13b22d7f` or `emulator-5554`) as the device name.
108 | 
109 | ### Usage Scenarios
110 | 
111 | | Scenario | Configuration Required | Behavior |
112 | |----------|----------------------|----------|
113 | | Single device connected | None | ✅ Auto-connects to the device |
114 | | Multiple devices, want specific one | `config.yaml` with `device.name` | ✅ Connects to specified device |
115 | | Multiple devices, no config | None | ❌ Shows error with available devices |
116 | | No devices connected | N/A | ❌ Shows "no devices" error |
117 | 
118 | **Note**: If you have multiple devices connected and don't specify which one to use, the server will show an error message listing all available devices.
119 | 
120 | ## Usage
121 | 
122 | An MCP client is needed to use this server. The Claude Desktop app is an example
123 | of an MCP client. To use this server with Claude Desktop:
124 | 
125 | 1. Locate your Claude Desktop configuration file:
126 | 
127 |    - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
128 |    - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
129 | 
130 | 2. Add the Android MCP server configuration to the `mcpServers` section:
131 | 
132 | ```json
133 | {
134 |   "mcpServers": {
135 |     "android": {
136 |       "command": "path/to/uv",
137 |       "args": ["--directory", "path/to/android-mcp-server", "run", "server.py"]
138 |     }
139 |   }
140 | }
141 | ```
142 | 
143 | Replace:
144 | 
145 | - `path/to/uv` with the actual path to your `uv` executable
146 | - `path/to/android-mcp-server` with the absolute path to where you cloned this
147 | repository
148 | 
149 | <https://github.com/user-attachments/assets/c45bbc17-f698-43e7-85b4-f1b39b8326a8>
150 | 
151 | ### Available Tools
152 | 
153 | The server exposes the following tools:
154 | 
155 | ```python
156 | def get_packages() -> str:
157 |     """
158 |     Get all installed packages on the device.
159 |     Returns:
160 |         str: A list of all installed packages on the device as a string
161 |     """
162 | ```
163 | 
164 | ```python
165 | def execute_adb_command(command: str) -> str:
166 |     """
167 |     Executes an ADB command and returns the output.
168 |     Args:
169 |         command (str): The ADB command to execute
170 |     Returns:
171 |         str: The output of the ADB command
172 |     """
173 | ```
174 | 
175 | ```python
176 | def get_uilayout() -> str:
177 |     """
178 |     Retrieves information about clickable elements in the current UI.
179 |     Returns a formatted string containing details about each clickable element,
180 |     including their text, content description, bounds, and center coordinates.
181 | 
182 |     Returns:
183 |         str: A formatted list of clickable elements with their properties
184 |     """
185 | ```
186 | 
187 | ```python
188 | def get_screenshot() -> Image:
189 |     """
190 |     Takes a screenshot of the device and returns it.
191 |     Returns:
192 |         Image: the screenshot
193 |     """
194 | ```
195 | 
196 | ```python
197 | def get_package_action_intents(package_name: str) -> list[str]:
198 |     """
199 |     Get all non-data actions from Activity Resolver Table for a package
200 |     Args:
201 |         package_name (str): The name of the package to get actions for
202 |     Returns:
203 |         list[str]: A list of all non-data actions from the Activity Resolver
204 |         Table for the package
205 |     """
206 | ```
207 | 
208 | ## Contributing
209 | 
210 | Contributions are welcome!
211 | 
212 | ## Acknowledgments
213 | 
214 | - Built with
215 | [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction)
216 | 
```

--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------

```python
1 | """
2 | Android MCP Server Tests
3 | 
4 | This package contains unit and integration tests for the Android MCP Server.
5 | """
6 | 
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [build-system]
 2 | requires = ["setuptools>=61.0", "wheel"]
 3 | build-backend = "setuptools.build_meta"
 4 | 
 5 | [project]
 6 | name = "android-mcp"
 7 | version = "0.1.0"
 8 | description = "An MCP server for android automation"
 9 | readme = "README.md"
10 | requires-python = ">=3.11"
11 | dependencies = [
12 |     "mcp>=1.0.0",
13 |     "pure-python-adb>=0.3.0.dev0",
14 |     "PyYAML>=6.0",
15 |     "Pillow>=10.0.0",
16 | ]
17 | 
18 | [project.optional-dependencies]
19 | test = ["pytest>=8.0.0", "pytest-mock>=3.12.0", "pytest-cov>=4.0.0"]
20 | 
21 | [tool.setuptools]
22 | py-modules = ["server", "adbdevicemanager"]
23 | 
24 | [tool.pytest.ini_options]
25 | testpaths = ["tests"]
26 | python_files = ["test_*.py"]
27 | python_classes = ["Test*"]
28 | python_functions = ["test_*"]
29 | addopts = ["--strict-markers", "--disable-warnings", "-v"]
30 | 
```

--------------------------------------------------------------------------------
/run_tests.py:
--------------------------------------------------------------------------------

```python
 1 | #!/usr/bin/env python3
 2 | """
 3 | Test runner script for Android MCP Server
 4 | 
 5 | This script installs test dependencies and runs the complete test suite.
 6 | """
 7 | 
 8 | import os
 9 | import subprocess
10 | import sys
11 | 
12 | 
13 | def run_command(command, description):
14 |     """Run a command and handle errors"""
15 |     print(f"\n{'='*60}")
16 |     print(f"Running: {description}")
17 |     print(f"Command: {command}")
18 |     print(f"{'='*60}")
19 | 
20 |     try:
21 |         result = subprocess.run(command, shell=True,
22 |                                 check=True, capture_output=True, text=True)
23 |         if result.stdout:
24 |             print(result.stdout)
25 |         return True
26 |     except subprocess.CalledProcessError as e:
27 |         print(f"Error running command: {e}")
28 |         if e.stdout:
29 |             print(f"STDOUT: {e.stdout}")
30 |         if e.stderr:
31 |             print(f"STDERR: {e.stderr}")
32 |         return False
33 | 
34 | 
35 | def main():
36 |     """Main test runner function"""
37 |     print("Android MCP Server Test Runner")
38 |     print("=" * 60)
39 | 
40 |     # Change to the script directory
41 |     script_dir = os.path.dirname(os.path.abspath(__file__))
42 |     os.chdir(script_dir)
43 |     print(f"Working directory: {script_dir}")
44 | 
45 |     # Install test dependencies
46 |     if not run_command("pip install -e .[test]", "Installing test dependencies"):
47 |         print("Failed to install test dependencies")
48 |         return 1
49 | 
50 |     # Run tests with coverage
51 |     if not run_command("pytest tests/ -v --cov=. --cov-report=term-missing", "Running tests with coverage"):
52 |         print("Tests failed")
53 |         return 1
54 | 
55 |     print("\n" + "="*60)
56 |     print("All tests passed successfully!")
57 |     print("="*60)
58 |     return 0
59 | 
60 | 
61 | if __name__ == "__main__":
62 |     sys.exit(main())
63 | 
```

--------------------------------------------------------------------------------
/server.py:
--------------------------------------------------------------------------------

```python
  1 | import os
  2 | import sys
  3 | 
  4 | import yaml
  5 | from mcp.server.fastmcp import FastMCP, Image
  6 | 
  7 | from adbdevicemanager import AdbDeviceManager
  8 | 
  9 | CONFIG_FILE = "config.yaml"
 10 | CONFIG_FILE_EXAMPLE = "config.yaml.example"
 11 | 
 12 | # Load config (make config file optional)
 13 | config = {}
 14 | device_name = None
 15 | 
 16 | if os.path.exists(CONFIG_FILE):
 17 |     try:
 18 |         with open(CONFIG_FILE) as f:
 19 |             config = yaml.safe_load(f.read()) or {}
 20 |         device_config = config.get("device", {})
 21 |         configured_device_name = device_config.get(
 22 |             "name") if device_config else None
 23 | 
 24 |         # Support multiple ways to specify auto-selection:
 25 |         # 1. name: null (None in Python)
 26 |         # 2. name: "" (empty string)
 27 |         # 3. name field completely missing
 28 |         if configured_device_name and configured_device_name.strip():
 29 |             device_name = configured_device_name.strip()
 30 |             print(f"Loaded config from {CONFIG_FILE}")
 31 |             print(f"Configured device: {device_name}")
 32 |         else:
 33 |             print(f"Loaded config from {CONFIG_FILE}")
 34 |             print(
 35 |                 "No device specified in config, will auto-select if only one device connected")
 36 |     except Exception as e:
 37 |         print(f"Error loading config file {CONFIG_FILE}: {e}", file=sys.stderr)
 38 |         print(
 39 |             f"Please check the format of your config file or recreate it from {CONFIG_FILE_EXAMPLE}", file=sys.stderr)
 40 |         sys.exit(1)
 41 | else:
 42 |     print(
 43 |         f"Config file {CONFIG_FILE} not found, using auto-selection for device")
 44 | 
 45 | # Initialize MCP and device manager
 46 | # AdbDeviceManager will handle auto-selection if device_name is None
 47 | mcp = FastMCP("android")
 48 | deviceManager = AdbDeviceManager(device_name)
 49 | 
 50 | 
 51 | @mcp.tool()
 52 | def get_packages() -> str:
 53 |     """
 54 |     Get all installed packages on the device
 55 |     Returns:
 56 |         str: A list of all installed packages on the device as a string
 57 |     """
 58 |     result = deviceManager.get_packages()
 59 |     return result
 60 | 
 61 | 
 62 | @mcp.tool()
 63 | def execute_adb_shell_command(command: str) -> str:
 64 |     """Executes an ADB command and returns the output or an error.
 65 |     Args:
 66 |         command (str): The ADB shell command to execute
 67 |     Returns:
 68 |         str: The output of the ADB command
 69 |     """
 70 |     result = deviceManager.execute_adb_shell_command(command)
 71 |     return result
 72 | 
 73 | 
 74 | @mcp.tool()
 75 | def get_uilayout() -> str:
 76 |     """
 77 |     Retrieves information about clickable elements in the current UI.
 78 |     Returns a formatted string containing details about each clickable element,
 79 |     including its text, content description, bounds, and center coordinates.
 80 | 
 81 |     Returns:
 82 |         str: A formatted list of clickable elements with their properties
 83 |     """
 84 |     result = deviceManager.get_uilayout()
 85 |     return result
 86 | 
 87 | 
 88 | @mcp.tool()
 89 | def get_screenshot() -> Image:
 90 |     """Takes a screenshot of the device and returns it.
 91 |     Returns:
 92 |         Image: the screenshot
 93 |     """
 94 |     deviceManager.take_screenshot()
 95 |     return Image(path="compressed_screenshot.png")
 96 | 
 97 | 
 98 | @mcp.tool()
 99 | def get_package_action_intents(package_name: str) -> list[str]:
100 |     """
101 |     Get all non-data actions from Activity Resolver Table for a package
102 |     Args:
103 |         package_name (str): The name of the package to get actions for
104 |     Returns:
105 |         list[str]: A list of all non-data actions from the Activity Resolver Table for the package
106 |     """
107 |     result = deviceManager.get_package_action_intents(package_name)
108 |     return result
109 | 
110 | 
111 | if __name__ == "__main__":
112 |     mcp.run(transport="stdio")
113 | 
```

--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Tests for configuration loading logic
  3 | """
  4 | 
  5 | import os
  6 | import tempfile
  7 | from unittest.mock import mock_open, patch
  8 | 
  9 | import pytest
 10 | import yaml
 11 | 
 12 | 
 13 | class TestConfigLoading:
 14 |     """Test configuration loading scenarios"""
 15 | 
 16 |     def setup_method(self):
 17 |         """Setup for each test method"""
 18 |         self.temp_dir = tempfile.mkdtemp()
 19 |         self.config_file = os.path.join(self.temp_dir, "config.yaml")
 20 | 
 21 |     def _load_config_logic(self, config_file_path):
 22 |         """
 23 |         Simulate the config loading logic from server.py
 24 |         Returns: (device_name, messages)
 25 |         """
 26 |         messages = []
 27 |         device_name = None
 28 | 
 29 |         if os.path.exists(config_file_path):
 30 |             try:
 31 |                 with open(config_file_path) as f:
 32 |                     config = yaml.safe_load(f.read()) or {}
 33 |                 device_config = config.get("device", {})
 34 |                 configured_device_name = device_config.get(
 35 |                     "name") if device_config else None
 36 | 
 37 |                 if configured_device_name and configured_device_name.strip():
 38 |                     device_name = configured_device_name.strip()
 39 |                     messages.append(f"Loaded config from {config_file_path}")
 40 |                     messages.append(f"Configured device: {device_name}")
 41 |                 else:
 42 |                     messages.append(f"Loaded config from {config_file_path}")
 43 |                     messages.append(
 44 |                         "No device specified in config, will auto-select if only one device connected")
 45 |             except Exception as e:
 46 |                 messages.append(
 47 |                     f"Error loading config file {config_file_path}: {e}")
 48 |                 raise
 49 |         else:
 50 |             messages.append(
 51 |                 f"Config file {config_file_path} not found, using auto-selection for device")
 52 | 
 53 |         return device_name, messages
 54 | 
 55 |     def test_no_config_file(self):
 56 |         """Test behavior when config file doesn't exist"""
 57 |         non_existent_file = os.path.join(self.temp_dir, "non_existent.yaml")
 58 | 
 59 |         device_name, messages = self._load_config_logic(non_existent_file)
 60 | 
 61 |         assert device_name is None
 62 |         assert any("not found" in msg for msg in messages)
 63 |         assert any("auto-selection" in msg for msg in messages)
 64 | 
 65 |     def test_config_with_null_name(self):
 66 |         """Test config with name: null"""
 67 |         config_content = """
 68 | device:
 69 |   name: null
 70 | """
 71 |         with open(self.config_file, 'w') as f:
 72 |             f.write(config_content)
 73 | 
 74 |         device_name, messages = self._load_config_logic(self.config_file)
 75 | 
 76 |         assert device_name is None
 77 |         assert any("Loaded config" in msg for msg in messages)
 78 |         assert any("auto-select" in msg for msg in messages)
 79 | 
 80 |     def test_config_with_empty_string_name(self):
 81 |         """Test config with name: ''"""
 82 |         config_content = """
 83 | device:
 84 |   name: ""
 85 | """
 86 |         with open(self.config_file, 'w') as f:
 87 |             f.write(config_content)
 88 | 
 89 |         device_name, messages = self._load_config_logic(self.config_file)
 90 | 
 91 |         assert device_name is None
 92 |         assert any("auto-select" in msg for msg in messages)
 93 | 
 94 |     def test_config_with_whitespace_name(self):
 95 |         """Test config with name containing only whitespace"""
 96 |         config_content = """
 97 | device:
 98 |   name: "   \t  \n  "
 99 | """
100 |         with open(self.config_file, 'w') as f:
101 |             f.write(config_content)
102 | 
103 |         device_name, messages = self._load_config_logic(self.config_file)
104 | 
105 |         assert device_name is None
106 |         assert any("auto-select" in msg for msg in messages)
107 | 
108 |     def test_config_without_name_field(self):
109 |         """Test config with device section but no name field"""
110 |         config_content = """
111 | device:
112 |   # name field is missing
113 |   other_setting: value
114 | """
115 |         with open(self.config_file, 'w') as f:
116 |             f.write(config_content)
117 | 
118 |         device_name, messages = self._load_config_logic(self.config_file)
119 | 
120 |         assert device_name is None
121 |         assert any("auto-select" in msg for msg in messages)
122 | 
123 |     def test_config_without_device_section(self):
124 |         """Test config without device section"""
125 |         config_content = """
126 | # No device section
127 | other_config: value
128 | """
129 |         with open(self.config_file, 'w') as f:
130 |             f.write(config_content)
131 | 
132 |         device_name, messages = self._load_config_logic(self.config_file)
133 | 
134 |         assert device_name is None
135 |         assert any("auto-select" in msg for msg in messages)
136 | 
137 |     def test_config_with_valid_device_name(self):
138 |         """Test config with valid device name"""
139 |         config_content = """
140 | device:
141 |   name: "test-device-123"
142 | """
143 |         with open(self.config_file, 'w') as f:
144 |             f.write(config_content)
145 | 
146 |         device_name, messages = self._load_config_logic(self.config_file)
147 | 
148 |         assert device_name == "test-device-123"
149 |         assert any(
150 |             "Configured device: test-device-123" in msg for msg in messages)
151 | 
152 |     def test_config_with_device_name_with_whitespace(self):
153 |         """Test config with device name that has surrounding whitespace"""
154 |         config_content = """
155 | device:
156 |   name: "  test-device-123  "
157 | """
158 |         with open(self.config_file, 'w') as f:
159 |             f.write(config_content)
160 | 
161 |         device_name, messages = self._load_config_logic(self.config_file)
162 | 
163 |         assert device_name == "test-device-123"  # Should be trimmed
164 |         assert any(
165 |             "Configured device: test-device-123" in msg for msg in messages)
166 | 
167 |     def test_invalid_yaml_config(self):
168 |         """Test behavior with invalid YAML"""
169 |         config_content = """
170 | device:
171 |   name: "test-device
172 |   # Missing closing quote - invalid YAML
173 | """
174 |         with open(self.config_file, 'w') as f:
175 |             f.write(config_content)
176 | 
177 |         with pytest.raises(Exception):
178 |             self._load_config_logic(self.config_file)
179 | 
180 |     def test_empty_config_file(self):
181 |         """Test behavior with empty config file"""
182 |         with open(self.config_file, 'w') as f:
183 |             f.write("")
184 | 
185 |         device_name, messages = self._load_config_logic(self.config_file)
186 | 
187 |         assert device_name is None
188 |         assert any("auto-select" in msg for msg in messages)
189 | 
190 |     def teardown_method(self):
191 |         """Cleanup after each test method"""
192 |         if os.path.exists(self.config_file):
193 |             os.remove(self.config_file)
194 |         os.rmdir(self.temp_dir)
195 | 
```

--------------------------------------------------------------------------------
/tests/test_adb_device_manager.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Tests for AdbDeviceManager
  3 | """
  4 | 
  5 | import os
  6 | import sys
  7 | from unittest.mock import MagicMock, patch
  8 | 
  9 | import pytest
 10 | 
 11 | from adbdevicemanager import AdbDeviceManager
 12 | 
 13 | # Add the parent directory to the path so we can import our modules
 14 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 15 | 
 16 | 
 17 | class TestAdbDeviceManager:
 18 |     """Test AdbDeviceManager functionality"""
 19 | 
 20 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
 21 |     @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
 22 |     @patch('adbdevicemanager.AdbClient')
 23 |     def test_single_device_auto_selection(self, mock_adb_client, mock_get_devices, mock_check_adb):
 24 |         """Test auto-selection when only one device is connected"""
 25 |         # Setup mocks
 26 |         mock_check_adb.return_value = True
 27 |         mock_get_devices.return_value = ["device123"]
 28 |         mock_device = MagicMock()
 29 |         mock_adb_client.return_value.device.return_value = mock_device
 30 | 
 31 |         # Test with device_name=None (auto-selection)
 32 |         with patch('builtins.print') as mock_print:
 33 |             manager = AdbDeviceManager(device_name=None, exit_on_error=False)
 34 | 
 35 |             # Verify the correct device was selected
 36 |             mock_adb_client.return_value.device.assert_called_once_with(
 37 |                 "device123")
 38 |             assert manager.device == mock_device
 39 | 
 40 |             # Verify auto-selection message was printed
 41 |             mock_print.assert_called_with(
 42 |                 "No device specified, automatically selected: device123")
 43 | 
 44 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
 45 |     @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
 46 |     def test_multiple_devices_no_selection_error(self, mock_get_devices, mock_check_adb):
 47 |         """Test error when multiple devices are connected but none specified"""
 48 |         # Setup mocks
 49 |         mock_check_adb.return_value = True
 50 |         mock_get_devices.return_value = ["device123", "device456"]
 51 | 
 52 |         # Test with device_name=None and multiple devices
 53 |         with pytest.raises(RuntimeError) as exc_info:
 54 |             AdbDeviceManager(device_name=None, exit_on_error=False)
 55 | 
 56 |         assert "Multiple devices connected" in str(exc_info.value)
 57 |         assert "device123" in str(exc_info.value)
 58 |         assert "device456" in str(exc_info.value)
 59 | 
 60 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
 61 |     @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
 62 |     @patch('adbdevicemanager.AdbClient')
 63 |     def test_specific_device_selection(self, mock_adb_client, mock_get_devices, mock_check_adb):
 64 |         """Test selecting a specific device"""
 65 |         # Setup mocks
 66 |         mock_check_adb.return_value = True
 67 |         mock_get_devices.return_value = ["device123", "device456"]
 68 |         mock_device = MagicMock()
 69 |         mock_adb_client.return_value.device.return_value = mock_device
 70 | 
 71 |         # Test with specific device name
 72 |         manager = AdbDeviceManager(
 73 |             device_name="device456", exit_on_error=False)
 74 | 
 75 |         # Verify the correct device was selected
 76 |         mock_adb_client.return_value.device.assert_called_once_with(
 77 |             "device456")
 78 |         assert manager.device == mock_device
 79 | 
 80 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
 81 |     @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
 82 |     def test_device_not_found_error(self, mock_get_devices, mock_check_adb):
 83 |         """Test error when specified device is not found"""
 84 |         # Setup mocks
 85 |         mock_check_adb.return_value = True
 86 |         mock_get_devices.return_value = ["device123", "device456"]
 87 | 
 88 |         # Test with non-existent device
 89 |         with pytest.raises(RuntimeError) as exc_info:
 90 |             AdbDeviceManager(device_name="non-existent-device",
 91 |                              exit_on_error=False)
 92 | 
 93 |         assert "Device non-existent-device not found" in str(exc_info.value)
 94 |         assert "Available devices" in str(exc_info.value)
 95 | 
 96 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
 97 |     @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
 98 |     def test_no_devices_connected_error(self, mock_get_devices, mock_check_adb):
 99 |         """Test error when no devices are connected"""
100 |         # Setup mocks
101 |         mock_check_adb.return_value = True
102 |         mock_get_devices.return_value = []
103 | 
104 |         # Test with no devices
105 |         with pytest.raises(RuntimeError) as exc_info:
106 |             AdbDeviceManager(device_name=None, exit_on_error=False)
107 | 
108 |         assert "No devices connected" in str(exc_info.value)
109 | 
110 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
111 |     def test_adb_not_installed_error(self, mock_check_adb):
112 |         """Test error when ADB is not installed"""
113 |         # Setup mocks
114 |         mock_check_adb.return_value = False
115 | 
116 |         # Test with ADB not installed
117 |         with pytest.raises(RuntimeError) as exc_info:
118 |             AdbDeviceManager(device_name=None, exit_on_error=False)
119 | 
120 |         assert "adb is not installed" in str(exc_info.value)
121 | 
122 |     @patch('subprocess.run')
123 |     def test_check_adb_installed_success(self, mock_run):
124 |         """Test successful ADB installation check"""
125 |         mock_run.return_value = MagicMock()  # Successful run
126 | 
127 |         result = AdbDeviceManager.check_adb_installed()
128 | 
129 |         assert result is True
130 |         mock_run.assert_called_once()
131 | 
132 |     @patch('subprocess.run')
133 |     def test_check_adb_installed_failure(self, mock_run):
134 |         """Test failed ADB installation check"""
135 |         mock_run.side_effect = FileNotFoundError()  # ADB not found
136 | 
137 |         result = AdbDeviceManager.check_adb_installed()
138 | 
139 |         assert result is False
140 | 
141 |     @patch('adbdevicemanager.AdbClient')
142 |     def test_get_available_devices(self, mock_adb_client):
143 |         """Test getting available devices"""
144 |         # Setup mock devices
145 |         mock_device1 = MagicMock()
146 |         mock_device1.serial = "device123"
147 |         mock_device2 = MagicMock()
148 |         mock_device2.serial = "device456"
149 | 
150 |         mock_adb_client.return_value.devices.return_value = [
151 |             mock_device1, mock_device2]
152 | 
153 |         devices = AdbDeviceManager.get_available_devices()
154 | 
155 |         assert devices == ["device123", "device456"]
156 | 
157 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
158 |     @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
159 |     @patch('adbdevicemanager.AdbClient')
160 |     def test_exit_on_error_true(self, mock_adb_client, mock_get_devices, mock_check_adb):
161 |         """Test that exit_on_error=True calls sys.exit"""
162 |         # Setup mocks to trigger error
163 |         mock_check_adb.return_value = True
164 |         mock_get_devices.return_value = []  # No devices
165 | 
166 |         # Test with exit_on_error=True (default)
167 |         with patch('sys.exit') as mock_exit:
168 |             with patch('builtins.print'):  # Suppress error output
169 |                 AdbDeviceManager(device_name=None, exit_on_error=True)
170 | 
171 |             mock_exit.assert_called_once_with(1)
172 | 
```

--------------------------------------------------------------------------------
/adbdevicemanager.py:
--------------------------------------------------------------------------------

```python
  1 | import os
  2 | import subprocess
  3 | import sys
  4 | 
  5 | from PIL import Image as PILImage
  6 | from ppadb.client import Client as AdbClient
  7 | 
  8 | 
  9 | class AdbDeviceManager:
 10 |     def __init__(self, device_name: str | None = None, exit_on_error: bool = True) -> None:
 11 |         """
 12 |         Initialize the ADB Device Manager
 13 | 
 14 |         Args:
 15 |             device_name: Optional name/serial of the device to manage.
 16 |                          If None, attempts to auto-select if only one device is available.
 17 |             exit_on_error: Whether to exit the program if device initialization fails
 18 |         """
 19 |         if not self.check_adb_installed():
 20 |             error_msg = "adb is not installed or not in PATH. Please install adb and ensure it is in your PATH."
 21 |             if exit_on_error:
 22 |                 print(error_msg, file=sys.stderr)
 23 |                 sys.exit(1)
 24 |             else:
 25 |                 raise RuntimeError(error_msg)
 26 | 
 27 |         available_devices = self.get_available_devices()
 28 |         if not available_devices:
 29 |             error_msg = "No devices connected. Please connect a device and try again."
 30 |             if exit_on_error:
 31 |                 print(error_msg, file=sys.stderr)
 32 |                 sys.exit(1)
 33 |             else:
 34 |                 raise RuntimeError(error_msg)
 35 | 
 36 |         selected_device_name: str | None = None
 37 | 
 38 |         if device_name:
 39 |             if device_name not in available_devices:
 40 |                 error_msg = f"Device {device_name} not found. Available devices: {available_devices}"
 41 |                 if exit_on_error:
 42 |                     print(error_msg, file=sys.stderr)
 43 |                     sys.exit(1)
 44 |                 else:
 45 |                     raise RuntimeError(error_msg)
 46 |             selected_device_name = device_name
 47 |         else:  # No device_name provided, try auto-selection
 48 |             if len(available_devices) == 1:
 49 |                 selected_device_name = available_devices[0]
 50 |                 print(
 51 |                     f"No device specified, automatically selected: {selected_device_name}")
 52 |             elif len(available_devices) > 1:
 53 |                 error_msg = f"Multiple devices connected: {available_devices}. Please specify a device in config.yaml or connect only one device."
 54 |                 if exit_on_error:
 55 |                     print(error_msg, file=sys.stderr)
 56 |                     sys.exit(1)
 57 |                 else:
 58 |                     raise RuntimeError(error_msg)
 59 |             # If len(available_devices) == 0, it's already caught by the earlier check
 60 | 
 61 |         # At this point, selected_device_name should always be set due to the logic above
 62 |         # Initialize the device
 63 |         self.device = AdbClient().device(selected_device_name)
 64 | 
 65 |     @staticmethod
 66 |     def check_adb_installed() -> bool:
 67 |         """Check if ADB is installed on the system."""
 68 |         try:
 69 |             subprocess.run(["adb", "version"], check=True,
 70 |                            stdout=subprocess.PIPE)
 71 |             return True
 72 |         except (subprocess.CalledProcessError, FileNotFoundError):
 73 |             return False
 74 | 
 75 |     @staticmethod
 76 |     def get_available_devices() -> list[str]:
 77 |         """Get a list of available devices."""
 78 |         return [device.serial for device in AdbClient().devices()]
 79 | 
 80 |     def get_packages(self) -> str:
 81 |         command = "pm list packages"
 82 |         packages = self.device.shell(command).strip().split("\n")
 83 |         result = [package[8:] for package in packages]
 84 |         output = "\n".join(result)
 85 |         return output
 86 | 
 87 |     def get_package_action_intents(self, package_name: str) -> list[str]:
 88 |         command = f"dumpsys package {package_name}"
 89 |         output = self.device.shell(command)
 90 | 
 91 |         resolver_table_start = output.find("Activity Resolver Table:")
 92 |         if resolver_table_start == -1:
 93 |             return []
 94 |         resolver_section = output[resolver_table_start:]
 95 | 
 96 |         non_data_start = resolver_section.find("\n  Non-Data Actions:")
 97 |         if non_data_start == -1:
 98 |             return []
 99 | 
100 |         section_end = resolver_section[non_data_start:].find("\n\n")
101 |         if section_end == -1:
102 |             non_data_section = resolver_section[non_data_start:]
103 |         else:
104 |             non_data_section = resolver_section[
105 |                 non_data_start: non_data_start + section_end
106 |             ]
107 | 
108 |         actions = []
109 |         for line in non_data_section.split("\n"):
110 |             line = line.strip()
111 |             if line.startswith("android.") or line.startswith("com."):
112 |                 actions.append(line)
113 | 
114 |         return actions
115 | 
116 |     def execute_adb_shell_command(self, command: str) -> str:
117 |         """Executes an ADB command and returns the output."""
118 |         if command.startswith("adb shell "):
119 |             command = command[10:]
120 |         elif command.startswith("adb "):
121 |             command = command[4:]
122 |         result = self.device.shell(command)
123 |         return result
124 | 
125 |     def take_screenshot(self) -> None:
126 |         self.device.shell("screencap -p /sdcard/screenshot.png")
127 |         self.device.pull("/sdcard/screenshot.png", "screenshot.png")
128 |         self.device.shell("rm /sdcard/screenshot.png")
129 | 
130 |         # compressing the ss to avoid "maximum call stack exceeded" error on claude desktop
131 |         with PILImage.open("screenshot.png") as img:
132 |             width, height = img.size
133 |             new_width = int(width * 0.3)
134 |             new_height = int(height * 0.3)
135 |             resized_img = img.resize(
136 |                 (new_width, new_height), PILImage.Resampling.LANCZOS
137 |             )
138 | 
139 |             resized_img.save(
140 |                 "compressed_screenshot.png", "PNG", quality=85, optimize=True
141 |             )
142 | 
143 |     def get_uilayout(self) -> str:
144 |         self.device.shell("uiautomator dump")
145 |         self.device.pull("/sdcard/window_dump.xml", "window_dump.xml")
146 |         self.device.shell("rm /sdcard/window_dump.xml")
147 | 
148 |         import re
149 |         import xml.etree.ElementTree as ET
150 | 
151 |         def calculate_center(bounds_str):
152 |             matches = re.findall(r"\[(\d+),(\d+)\]", bounds_str)
153 |             if len(matches) == 2:
154 |                 x1, y1 = map(int, matches[0])
155 |                 x2, y2 = map(int, matches[1])
156 |                 center_x = (x1 + x2) // 2
157 |                 center_y = (y1 + y2) // 2
158 |                 return center_x, center_y
159 |             return None
160 | 
161 |         tree = ET.parse("window_dump.xml")
162 |         root = tree.getroot()
163 | 
164 |         clickable_elements = []
165 |         for element in root.findall(".//node[@clickable='true']"):
166 |             text = element.get("text", "")
167 |             content_desc = element.get("content-desc", "")
168 |             bounds = element.get("bounds", "")
169 | 
170 |             # Only include elements that have either text or content description
171 |             if text or content_desc:
172 |                 center = calculate_center(bounds)
173 |                 element_info = "Clickable element:"
174 |                 if text:
175 |                     element_info += f"\n  Text: {text}"
176 |                 if content_desc:
177 |                     element_info += f"\n  Description: {content_desc}"
178 |                 element_info += f"\n  Bounds: {bounds}"
179 |                 if center:
180 |                     element_info += f"\n  Center: ({center[0]}, {center[1]})"
181 |                 clickable_elements.append(element_info)
182 | 
183 |         if not clickable_elements:
184 |             return "No clickable elements found with text or description"
185 |         else:
186 |             result = "\n\n".join(clickable_elements)
187 |             return result
188 | 
```

--------------------------------------------------------------------------------
/tests/test_integration.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Integration tests for the complete server initialization flow
  3 | """
  4 | 
  5 | import os
  6 | import sys
  7 | import tempfile
  8 | from unittest.mock import MagicMock, patch
  9 | 
 10 | from adbdevicemanager import AdbDeviceManager
 11 | 
 12 | # Add the parent directory to the path so we can import our modules
 13 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 14 | 
 15 | 
 16 | class TestServerIntegration:
 17 |     """Test complete server initialization scenarios"""
 18 | 
 19 |     def setup_method(self):
 20 |         """Setup for each test method"""
 21 |         self.temp_dir = tempfile.mkdtemp()
 22 |         self.config_file = os.path.join(self.temp_dir, "config.yaml")
 23 | 
 24 |     def _simulate_server_initialization(self, config_file_path):
 25 |         """
 26 |         Simulate the complete server initialization process
 27 |         Returns: (device_manager, messages)
 28 |         """
 29 |         import yaml
 30 | 
 31 |         messages = []
 32 |         config = {}
 33 |         device_name = None
 34 | 
 35 |         if os.path.exists(config_file_path):
 36 |             try:
 37 |                 with open(config_file_path) as f:
 38 |                     config = yaml.safe_load(f.read()) or {}
 39 |                 device_config = config.get("device", {})
 40 |                 configured_device_name = device_config.get(
 41 |                     "name") if device_config else None
 42 | 
 43 |                 if configured_device_name and configured_device_name.strip():
 44 |                     device_name = configured_device_name.strip()
 45 |                     messages.append(f"Loaded config from {config_file_path}")
 46 |                     messages.append(f"Configured device: {device_name}")
 47 |                 else:
 48 |                     messages.append(f"Loaded config from {config_file_path}")
 49 |                     messages.append(
 50 |                         "No device specified in config, will auto-select if only one device connected")
 51 |             except Exception as e:
 52 |                 messages.append(
 53 |                     f"Error loading config file {config_file_path}: {e}")
 54 |                 raise
 55 |         else:
 56 |             messages.append(
 57 |                 f"Config file {config_file_path} not found, using auto-selection for device")
 58 | 
 59 |         # Initialize device manager (with mocked dependencies)
 60 |         device_manager = AdbDeviceManager(device_name, exit_on_error=False)
 61 | 
 62 |         return device_manager, messages
 63 | 
 64 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
 65 |     @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
 66 |     @patch('adbdevicemanager.AdbClient')
 67 |     def test_no_config_auto_selection_success(self, mock_adb_client, mock_get_devices, mock_check_adb):
 68 |         """Test successful server start with no config file and single device"""
 69 |         # Setup mocks
 70 |         mock_check_adb.return_value = True
 71 |         mock_get_devices.return_value = ["device123"]
 72 |         mock_device = MagicMock()
 73 |         mock_adb_client.return_value.device.return_value = mock_device
 74 | 
 75 |         # Use non-existent config file
 76 |         non_existent_config = os.path.join(self.temp_dir, "non_existent.yaml")
 77 | 
 78 |         with patch('builtins.print') as mock_print:
 79 |             device_manager, messages = self._simulate_server_initialization(
 80 |                 non_existent_config)
 81 | 
 82 |         # Verify results
 83 |         assert device_manager.device == mock_device
 84 |         assert any("not found" in msg for msg in messages)
 85 |         assert any("auto-selection" in msg for msg in messages)
 86 |         mock_print.assert_called_with(
 87 |             "No device specified, automatically selected: device123")
 88 | 
 89 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
 90 |     @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
 91 |     @patch('adbdevicemanager.AdbClient')
 92 |     def test_config_with_null_device_auto_selection(self, mock_adb_client, mock_get_devices, mock_check_adb):
 93 |         """Test server start with config file containing name: null"""
 94 |         # Setup mocks
 95 |         mock_check_adb.return_value = True
 96 |         mock_get_devices.return_value = ["device456"]
 97 |         mock_device = MagicMock()
 98 |         mock_adb_client.return_value.device.return_value = mock_device
 99 | 
100 |         # Create config with null device name
101 |         config_content = """
102 | device:
103 |   name: null
104 | """
105 |         with open(self.config_file, 'w') as f:
106 |             f.write(config_content)
107 | 
108 |         with patch('builtins.print') as mock_print:
109 |             device_manager, messages = self._simulate_server_initialization(
110 |                 self.config_file)
111 | 
112 |         # Verify results
113 |         assert device_manager.device == mock_device
114 |         assert any("Loaded config" in msg for msg in messages)
115 |         assert any("auto-select" in msg for msg in messages)
116 |         mock_print.assert_called_with(
117 |             "No device specified, automatically selected: device456")
118 | 
119 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
120 |     @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
121 |     @patch('adbdevicemanager.AdbClient')
122 |     def test_config_with_specific_device(self, mock_adb_client, mock_get_devices, mock_check_adb):
123 |         """Test server start with config file specifying a device"""
124 |         # Setup mocks
125 |         mock_check_adb.return_value = True
126 |         mock_get_devices.return_value = ["device123", "device456"]
127 |         mock_device = MagicMock()
128 |         mock_adb_client.return_value.device.return_value = mock_device
129 | 
130 |         # Create config with specific device name
131 |         config_content = """
132 | device:
133 |   name: "device456"
134 | """
135 |         with open(self.config_file, 'w') as f:
136 |             f.write(config_content)
137 | 
138 |         device_manager, messages = self._simulate_server_initialization(
139 |             self.config_file)
140 | 
141 |         # Verify results
142 |         assert device_manager.device == mock_device
143 |         mock_adb_client.return_value.device.assert_called_once_with(
144 |             "device456")
145 |         assert any("Configured device: device456" in msg for msg in messages)
146 | 
147 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
148 |     @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
149 |     def test_multiple_devices_no_config_error(self, mock_get_devices, mock_check_adb):
150 |         """Test server initialization fails with multiple devices and no config"""
151 |         # Setup mocks
152 |         mock_check_adb.return_value = True
153 |         mock_get_devices.return_value = ["device123", "device456"]
154 | 
155 |         # Use non-existent config file
156 |         non_existent_config = os.path.join(self.temp_dir, "non_existent.yaml")
157 | 
158 |         try:
159 |             device_manager, messages = self._simulate_server_initialization(
160 |                 non_existent_config)
161 |             assert False, "Should have raised an exception"
162 |         except RuntimeError as e:
163 |             assert "Multiple devices connected" in str(e)
164 |             assert "device123" in str(e)
165 |             assert "device456" in str(e)
166 | 
167 |     @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
168 |     @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
169 |     def test_device_not_found_error(self, mock_get_devices, mock_check_adb):
170 |         """Test server initialization fails when specified device is not found"""
171 |         # Setup mocks
172 |         mock_check_adb.return_value = True
173 |         mock_get_devices.return_value = ["device123"]
174 | 
175 |         # Create config with non-existent device name
176 |         config_content = """
177 | device:
178 |   name: "non-existent-device"
179 | """
180 |         with open(self.config_file, 'w') as f:
181 |             f.write(config_content)
182 | 
183 |         try:
184 |             device_manager, messages = self._simulate_server_initialization(
185 |                 self.config_file)
186 |             assert False, "Should have raised an exception"
187 |         except RuntimeError as e:
188 |             assert "Device non-existent-device not found" in str(e)
189 |             assert "Available devices" in str(e)
190 | 
191 |     def teardown_method(self):
192 |         """Cleanup after each test method"""
193 |         if os.path.exists(self.config_file):
194 |             os.remove(self.config_file)
195 |         os.rmdir(self.temp_dir)
196 | 
```