#
tokens: 8902/50000 11/11 files
lines: off (toggle) GitHub
raw markdown copy
# 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:
--------------------------------------------------------------------------------

```
3.11

```

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

```
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv

# Project specific files
*.png
window_dump.xml
output.mov
config.yaml
test.py
.coverage

```

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

```markdown
# Android MCP Server

An MCP (Model Context Protocol) server that provides programmatic control over
Android devices through ADB (Android Debug Bridge). This server exposes
various Android device management capabilities that can be accessed by MCP
clients like [Claude desktop](https://modelcontextprotocol.io/quickstart/user)
and Code editors
(e.g. [Cursor](https://docs.cursor.com/context/model-context-protocol))

## Features

- 🔧 ADB Command Execution
- 📸 Device Screenshot Capture
- 🎯 UI Layout Analysis
- 📱 Device Package Management

## Prerequisites

- Python 3.x
- ADB (Android Debug Bridge) installed and configured
- Android device or emulator (not tested)

## Installation

1. Clone the repository:

```bash
git clone https://github.com/minhalvp/android-mcp-server.git
cd android-mcp-server
```

2. Install dependencies:
This project uses [uv](https://github.com/astral-sh/uv) for project
management via various methods of
[installation](https://docs.astral.sh/uv/getting-started/installation/).

```bash
uv python install 3.11
uv sync
```

## Configuration

The server supports flexible device configuration with multiple usage scenarios.

### Device Selection Modes

**1. Automatic Selection (Recommended for single device)**

- No configuration file needed
- Automatically connects to the only connected device
- Perfect for development with a single test device

**2. Manual Device Selection**

- Use when you have multiple devices connected
- Specify exact device in configuration file

### Configuration File (Optional)

The configuration file (`config.yaml`) is **optional**. If not present, the server will automatically select the device if only one is connected.

#### For Automatic Selection

Simply ensure only one device is connected and run the server - no configuration needed!

#### For Manual Selection

1. Create a configuration file:

```bash
cp config.yaml.example config.yaml
```

2. Edit `config.yaml` and specify your device:

```yaml
device:
  name: "your-device-serial-here" # Device identifier from 'adb devices'
```

**For auto-selection**, you can use any of these methods:

```yaml
device:
  name: null              # Explicit null (recommended)
  # name: ""              # Empty string  
  # name:                 # Or leave empty/comment out
```

### Finding Your Device Serial

To find your device identifier, run:

```bash
adb devices
```

Example output:

```
List of devices attached
13b22d7f        device
emulator-5554   device
```

Use the first column value (e.g., `13b22d7f` or `emulator-5554`) as the device name.

### Usage Scenarios

| Scenario | Configuration Required | Behavior |
|----------|----------------------|----------|
| Single device connected | None | ✅ Auto-connects to the device |
| Multiple devices, want specific one | `config.yaml` with `device.name` | ✅ Connects to specified device |
| Multiple devices, no config | None | ❌ Shows error with available devices |
| No devices connected | N/A | ❌ Shows "no devices" error |

**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.

## Usage

An MCP client is needed to use this server. The Claude Desktop app is an example
of an MCP client. To use this server with Claude Desktop:

1. Locate your Claude Desktop configuration file:

   - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
   - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`

2. Add the Android MCP server configuration to the `mcpServers` section:

```json
{
  "mcpServers": {
    "android": {
      "command": "path/to/uv",
      "args": ["--directory", "path/to/android-mcp-server", "run", "server.py"]
    }
  }
}
```

Replace:

- `path/to/uv` with the actual path to your `uv` executable
- `path/to/android-mcp-server` with the absolute path to where you cloned this
repository

<https://github.com/user-attachments/assets/c45bbc17-f698-43e7-85b4-f1b39b8326a8>

### Available Tools

The server exposes the following tools:

```python
def get_packages() -> str:
    """
    Get all installed packages on the device.
    Returns:
        str: A list of all installed packages on the device as a string
    """
```

```python
def execute_adb_command(command: str) -> str:
    """
    Executes an ADB command and returns the output.
    Args:
        command (str): The ADB command to execute
    Returns:
        str: The output of the ADB command
    """
```

```python
def get_uilayout() -> str:
    """
    Retrieves information about clickable elements in the current UI.
    Returns a formatted string containing details about each clickable element,
    including their text, content description, bounds, and center coordinates.

    Returns:
        str: A formatted list of clickable elements with their properties
    """
```

```python
def get_screenshot() -> Image:
    """
    Takes a screenshot of the device and returns it.
    Returns:
        Image: the screenshot
    """
```

```python
def get_package_action_intents(package_name: str) -> list[str]:
    """
    Get all non-data actions from Activity Resolver Table for a package
    Args:
        package_name (str): The name of the package to get actions for
    Returns:
        list[str]: A list of all non-data actions from the Activity Resolver
        Table for the package
    """
```

## Contributing

Contributions are welcome!

## Acknowledgments

- Built with
[Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction)

```

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

```python
"""
Android MCP Server Tests

This package contains unit and integration tests for the Android MCP Server.
"""

```

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

```toml
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "android-mcp"
version = "0.1.0"
description = "An MCP server for android automation"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
    "mcp>=1.0.0",
    "pure-python-adb>=0.3.0.dev0",
    "PyYAML>=6.0",
    "Pillow>=10.0.0",
]

[project.optional-dependencies]
test = ["pytest>=8.0.0", "pytest-mock>=3.12.0", "pytest-cov>=4.0.0"]

[tool.setuptools]
py-modules = ["server", "adbdevicemanager"]

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = ["--strict-markers", "--disable-warnings", "-v"]

```

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

```python
#!/usr/bin/env python3
"""
Test runner script for Android MCP Server

This script installs test dependencies and runs the complete test suite.
"""

import os
import subprocess
import sys


def run_command(command, description):
    """Run a command and handle errors"""
    print(f"\n{'='*60}")
    print(f"Running: {description}")
    print(f"Command: {command}")
    print(f"{'='*60}")

    try:
        result = subprocess.run(command, shell=True,
                                check=True, capture_output=True, text=True)
        if result.stdout:
            print(result.stdout)
        return True
    except subprocess.CalledProcessError as e:
        print(f"Error running command: {e}")
        if e.stdout:
            print(f"STDOUT: {e.stdout}")
        if e.stderr:
            print(f"STDERR: {e.stderr}")
        return False


def main():
    """Main test runner function"""
    print("Android MCP Server Test Runner")
    print("=" * 60)

    # Change to the script directory
    script_dir = os.path.dirname(os.path.abspath(__file__))
    os.chdir(script_dir)
    print(f"Working directory: {script_dir}")

    # Install test dependencies
    if not run_command("pip install -e .[test]", "Installing test dependencies"):
        print("Failed to install test dependencies")
        return 1

    # Run tests with coverage
    if not run_command("pytest tests/ -v --cov=. --cov-report=term-missing", "Running tests with coverage"):
        print("Tests failed")
        return 1

    print("\n" + "="*60)
    print("All tests passed successfully!")
    print("="*60)
    return 0


if __name__ == "__main__":
    sys.exit(main())

```

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

```python
import os
import sys

import yaml
from mcp.server.fastmcp import FastMCP, Image

from adbdevicemanager import AdbDeviceManager

CONFIG_FILE = "config.yaml"
CONFIG_FILE_EXAMPLE = "config.yaml.example"

# Load config (make config file optional)
config = {}
device_name = None

if os.path.exists(CONFIG_FILE):
    try:
        with open(CONFIG_FILE) as f:
            config = yaml.safe_load(f.read()) or {}
        device_config = config.get("device", {})
        configured_device_name = device_config.get(
            "name") if device_config else None

        # Support multiple ways to specify auto-selection:
        # 1. name: null (None in Python)
        # 2. name: "" (empty string)
        # 3. name field completely missing
        if configured_device_name and configured_device_name.strip():
            device_name = configured_device_name.strip()
            print(f"Loaded config from {CONFIG_FILE}")
            print(f"Configured device: {device_name}")
        else:
            print(f"Loaded config from {CONFIG_FILE}")
            print(
                "No device specified in config, will auto-select if only one device connected")
    except Exception as e:
        print(f"Error loading config file {CONFIG_FILE}: {e}", file=sys.stderr)
        print(
            f"Please check the format of your config file or recreate it from {CONFIG_FILE_EXAMPLE}", file=sys.stderr)
        sys.exit(1)
else:
    print(
        f"Config file {CONFIG_FILE} not found, using auto-selection for device")

# Initialize MCP and device manager
# AdbDeviceManager will handle auto-selection if device_name is None
mcp = FastMCP("android")
deviceManager = AdbDeviceManager(device_name)


@mcp.tool()
def get_packages() -> str:
    """
    Get all installed packages on the device
    Returns:
        str: A list of all installed packages on the device as a string
    """
    result = deviceManager.get_packages()
    return result


@mcp.tool()
def execute_adb_shell_command(command: str) -> str:
    """Executes an ADB command and returns the output or an error.
    Args:
        command (str): The ADB shell command to execute
    Returns:
        str: The output of the ADB command
    """
    result = deviceManager.execute_adb_shell_command(command)
    return result


@mcp.tool()
def get_uilayout() -> str:
    """
    Retrieves information about clickable elements in the current UI.
    Returns a formatted string containing details about each clickable element,
    including its text, content description, bounds, and center coordinates.

    Returns:
        str: A formatted list of clickable elements with their properties
    """
    result = deviceManager.get_uilayout()
    return result


@mcp.tool()
def get_screenshot() -> Image:
    """Takes a screenshot of the device and returns it.
    Returns:
        Image: the screenshot
    """
    deviceManager.take_screenshot()
    return Image(path="compressed_screenshot.png")


@mcp.tool()
def get_package_action_intents(package_name: str) -> list[str]:
    """
    Get all non-data actions from Activity Resolver Table for a package
    Args:
        package_name (str): The name of the package to get actions for
    Returns:
        list[str]: A list of all non-data actions from the Activity Resolver Table for the package
    """
    result = deviceManager.get_package_action_intents(package_name)
    return result


if __name__ == "__main__":
    mcp.run(transport="stdio")

```

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

```python
"""
Tests for configuration loading logic
"""

import os
import tempfile
from unittest.mock import mock_open, patch

import pytest
import yaml


class TestConfigLoading:
    """Test configuration loading scenarios"""

    def setup_method(self):
        """Setup for each test method"""
        self.temp_dir = tempfile.mkdtemp()
        self.config_file = os.path.join(self.temp_dir, "config.yaml")

    def _load_config_logic(self, config_file_path):
        """
        Simulate the config loading logic from server.py
        Returns: (device_name, messages)
        """
        messages = []
        device_name = None

        if os.path.exists(config_file_path):
            try:
                with open(config_file_path) as f:
                    config = yaml.safe_load(f.read()) or {}
                device_config = config.get("device", {})
                configured_device_name = device_config.get(
                    "name") if device_config else None

                if configured_device_name and configured_device_name.strip():
                    device_name = configured_device_name.strip()
                    messages.append(f"Loaded config from {config_file_path}")
                    messages.append(f"Configured device: {device_name}")
                else:
                    messages.append(f"Loaded config from {config_file_path}")
                    messages.append(
                        "No device specified in config, will auto-select if only one device connected")
            except Exception as e:
                messages.append(
                    f"Error loading config file {config_file_path}: {e}")
                raise
        else:
            messages.append(
                f"Config file {config_file_path} not found, using auto-selection for device")

        return device_name, messages

    def test_no_config_file(self):
        """Test behavior when config file doesn't exist"""
        non_existent_file = os.path.join(self.temp_dir, "non_existent.yaml")

        device_name, messages = self._load_config_logic(non_existent_file)

        assert device_name is None
        assert any("not found" in msg for msg in messages)
        assert any("auto-selection" in msg for msg in messages)

    def test_config_with_null_name(self):
        """Test config with name: null"""
        config_content = """
device:
  name: null
"""
        with open(self.config_file, 'w') as f:
            f.write(config_content)

        device_name, messages = self._load_config_logic(self.config_file)

        assert device_name is None
        assert any("Loaded config" in msg for msg in messages)
        assert any("auto-select" in msg for msg in messages)

    def test_config_with_empty_string_name(self):
        """Test config with name: ''"""
        config_content = """
device:
  name: ""
"""
        with open(self.config_file, 'w') as f:
            f.write(config_content)

        device_name, messages = self._load_config_logic(self.config_file)

        assert device_name is None
        assert any("auto-select" in msg for msg in messages)

    def test_config_with_whitespace_name(self):
        """Test config with name containing only whitespace"""
        config_content = """
device:
  name: "   \t  \n  "
"""
        with open(self.config_file, 'w') as f:
            f.write(config_content)

        device_name, messages = self._load_config_logic(self.config_file)

        assert device_name is None
        assert any("auto-select" in msg for msg in messages)

    def test_config_without_name_field(self):
        """Test config with device section but no name field"""
        config_content = """
device:
  # name field is missing
  other_setting: value
"""
        with open(self.config_file, 'w') as f:
            f.write(config_content)

        device_name, messages = self._load_config_logic(self.config_file)

        assert device_name is None
        assert any("auto-select" in msg for msg in messages)

    def test_config_without_device_section(self):
        """Test config without device section"""
        config_content = """
# No device section
other_config: value
"""
        with open(self.config_file, 'w') as f:
            f.write(config_content)

        device_name, messages = self._load_config_logic(self.config_file)

        assert device_name is None
        assert any("auto-select" in msg for msg in messages)

    def test_config_with_valid_device_name(self):
        """Test config with valid device name"""
        config_content = """
device:
  name: "test-device-123"
"""
        with open(self.config_file, 'w') as f:
            f.write(config_content)

        device_name, messages = self._load_config_logic(self.config_file)

        assert device_name == "test-device-123"
        assert any(
            "Configured device: test-device-123" in msg for msg in messages)

    def test_config_with_device_name_with_whitespace(self):
        """Test config with device name that has surrounding whitespace"""
        config_content = """
device:
  name: "  test-device-123  "
"""
        with open(self.config_file, 'w') as f:
            f.write(config_content)

        device_name, messages = self._load_config_logic(self.config_file)

        assert device_name == "test-device-123"  # Should be trimmed
        assert any(
            "Configured device: test-device-123" in msg for msg in messages)

    def test_invalid_yaml_config(self):
        """Test behavior with invalid YAML"""
        config_content = """
device:
  name: "test-device
  # Missing closing quote - invalid YAML
"""
        with open(self.config_file, 'w') as f:
            f.write(config_content)

        with pytest.raises(Exception):
            self._load_config_logic(self.config_file)

    def test_empty_config_file(self):
        """Test behavior with empty config file"""
        with open(self.config_file, 'w') as f:
            f.write("")

        device_name, messages = self._load_config_logic(self.config_file)

        assert device_name is None
        assert any("auto-select" in msg for msg in messages)

    def teardown_method(self):
        """Cleanup after each test method"""
        if os.path.exists(self.config_file):
            os.remove(self.config_file)
        os.rmdir(self.temp_dir)

```

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

```python
"""
Tests for AdbDeviceManager
"""

import os
import sys
from unittest.mock import MagicMock, patch

import pytest

from adbdevicemanager import AdbDeviceManager

# Add the parent directory to the path so we can import our modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))


class TestAdbDeviceManager:
    """Test AdbDeviceManager functionality"""

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
    @patch('adbdevicemanager.AdbClient')
    def test_single_device_auto_selection(self, mock_adb_client, mock_get_devices, mock_check_adb):
        """Test auto-selection when only one device is connected"""
        # Setup mocks
        mock_check_adb.return_value = True
        mock_get_devices.return_value = ["device123"]
        mock_device = MagicMock()
        mock_adb_client.return_value.device.return_value = mock_device

        # Test with device_name=None (auto-selection)
        with patch('builtins.print') as mock_print:
            manager = AdbDeviceManager(device_name=None, exit_on_error=False)

            # Verify the correct device was selected
            mock_adb_client.return_value.device.assert_called_once_with(
                "device123")
            assert manager.device == mock_device

            # Verify auto-selection message was printed
            mock_print.assert_called_with(
                "No device specified, automatically selected: device123")

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
    def test_multiple_devices_no_selection_error(self, mock_get_devices, mock_check_adb):
        """Test error when multiple devices are connected but none specified"""
        # Setup mocks
        mock_check_adb.return_value = True
        mock_get_devices.return_value = ["device123", "device456"]

        # Test with device_name=None and multiple devices
        with pytest.raises(RuntimeError) as exc_info:
            AdbDeviceManager(device_name=None, exit_on_error=False)

        assert "Multiple devices connected" in str(exc_info.value)
        assert "device123" in str(exc_info.value)
        assert "device456" in str(exc_info.value)

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
    @patch('adbdevicemanager.AdbClient')
    def test_specific_device_selection(self, mock_adb_client, mock_get_devices, mock_check_adb):
        """Test selecting a specific device"""
        # Setup mocks
        mock_check_adb.return_value = True
        mock_get_devices.return_value = ["device123", "device456"]
        mock_device = MagicMock()
        mock_adb_client.return_value.device.return_value = mock_device

        # Test with specific device name
        manager = AdbDeviceManager(
            device_name="device456", exit_on_error=False)

        # Verify the correct device was selected
        mock_adb_client.return_value.device.assert_called_once_with(
            "device456")
        assert manager.device == mock_device

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
    def test_device_not_found_error(self, mock_get_devices, mock_check_adb):
        """Test error when specified device is not found"""
        # Setup mocks
        mock_check_adb.return_value = True
        mock_get_devices.return_value = ["device123", "device456"]

        # Test with non-existent device
        with pytest.raises(RuntimeError) as exc_info:
            AdbDeviceManager(device_name="non-existent-device",
                             exit_on_error=False)

        assert "Device non-existent-device not found" in str(exc_info.value)
        assert "Available devices" in str(exc_info.value)

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
    def test_no_devices_connected_error(self, mock_get_devices, mock_check_adb):
        """Test error when no devices are connected"""
        # Setup mocks
        mock_check_adb.return_value = True
        mock_get_devices.return_value = []

        # Test with no devices
        with pytest.raises(RuntimeError) as exc_info:
            AdbDeviceManager(device_name=None, exit_on_error=False)

        assert "No devices connected" in str(exc_info.value)

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    def test_adb_not_installed_error(self, mock_check_adb):
        """Test error when ADB is not installed"""
        # Setup mocks
        mock_check_adb.return_value = False

        # Test with ADB not installed
        with pytest.raises(RuntimeError) as exc_info:
            AdbDeviceManager(device_name=None, exit_on_error=False)

        assert "adb is not installed" in str(exc_info.value)

    @patch('subprocess.run')
    def test_check_adb_installed_success(self, mock_run):
        """Test successful ADB installation check"""
        mock_run.return_value = MagicMock()  # Successful run

        result = AdbDeviceManager.check_adb_installed()

        assert result is True
        mock_run.assert_called_once()

    @patch('subprocess.run')
    def test_check_adb_installed_failure(self, mock_run):
        """Test failed ADB installation check"""
        mock_run.side_effect = FileNotFoundError()  # ADB not found

        result = AdbDeviceManager.check_adb_installed()

        assert result is False

    @patch('adbdevicemanager.AdbClient')
    def test_get_available_devices(self, mock_adb_client):
        """Test getting available devices"""
        # Setup mock devices
        mock_device1 = MagicMock()
        mock_device1.serial = "device123"
        mock_device2 = MagicMock()
        mock_device2.serial = "device456"

        mock_adb_client.return_value.devices.return_value = [
            mock_device1, mock_device2]

        devices = AdbDeviceManager.get_available_devices()

        assert devices == ["device123", "device456"]

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
    @patch('adbdevicemanager.AdbClient')
    def test_exit_on_error_true(self, mock_adb_client, mock_get_devices, mock_check_adb):
        """Test that exit_on_error=True calls sys.exit"""
        # Setup mocks to trigger error
        mock_check_adb.return_value = True
        mock_get_devices.return_value = []  # No devices

        # Test with exit_on_error=True (default)
        with patch('sys.exit') as mock_exit:
            with patch('builtins.print'):  # Suppress error output
                AdbDeviceManager(device_name=None, exit_on_error=True)

            mock_exit.assert_called_once_with(1)

```

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

```python
import os
import subprocess
import sys

from PIL import Image as PILImage
from ppadb.client import Client as AdbClient


class AdbDeviceManager:
    def __init__(self, device_name: str | None = None, exit_on_error: bool = True) -> None:
        """
        Initialize the ADB Device Manager

        Args:
            device_name: Optional name/serial of the device to manage.
                         If None, attempts to auto-select if only one device is available.
            exit_on_error: Whether to exit the program if device initialization fails
        """
        if not self.check_adb_installed():
            error_msg = "adb is not installed or not in PATH. Please install adb and ensure it is in your PATH."
            if exit_on_error:
                print(error_msg, file=sys.stderr)
                sys.exit(1)
            else:
                raise RuntimeError(error_msg)

        available_devices = self.get_available_devices()
        if not available_devices:
            error_msg = "No devices connected. Please connect a device and try again."
            if exit_on_error:
                print(error_msg, file=sys.stderr)
                sys.exit(1)
            else:
                raise RuntimeError(error_msg)

        selected_device_name: str | None = None

        if device_name:
            if device_name not in available_devices:
                error_msg = f"Device {device_name} not found. Available devices: {available_devices}"
                if exit_on_error:
                    print(error_msg, file=sys.stderr)
                    sys.exit(1)
                else:
                    raise RuntimeError(error_msg)
            selected_device_name = device_name
        else:  # No device_name provided, try auto-selection
            if len(available_devices) == 1:
                selected_device_name = available_devices[0]
                print(
                    f"No device specified, automatically selected: {selected_device_name}")
            elif len(available_devices) > 1:
                error_msg = f"Multiple devices connected: {available_devices}. Please specify a device in config.yaml or connect only one device."
                if exit_on_error:
                    print(error_msg, file=sys.stderr)
                    sys.exit(1)
                else:
                    raise RuntimeError(error_msg)
            # If len(available_devices) == 0, it's already caught by the earlier check

        # At this point, selected_device_name should always be set due to the logic above
        # Initialize the device
        self.device = AdbClient().device(selected_device_name)

    @staticmethod
    def check_adb_installed() -> bool:
        """Check if ADB is installed on the system."""
        try:
            subprocess.run(["adb", "version"], check=True,
                           stdout=subprocess.PIPE)
            return True
        except (subprocess.CalledProcessError, FileNotFoundError):
            return False

    @staticmethod
    def get_available_devices() -> list[str]:
        """Get a list of available devices."""
        return [device.serial for device in AdbClient().devices()]

    def get_packages(self) -> str:
        command = "pm list packages"
        packages = self.device.shell(command).strip().split("\n")
        result = [package[8:] for package in packages]
        output = "\n".join(result)
        return output

    def get_package_action_intents(self, package_name: str) -> list[str]:
        command = f"dumpsys package {package_name}"
        output = self.device.shell(command)

        resolver_table_start = output.find("Activity Resolver Table:")
        if resolver_table_start == -1:
            return []
        resolver_section = output[resolver_table_start:]

        non_data_start = resolver_section.find("\n  Non-Data Actions:")
        if non_data_start == -1:
            return []

        section_end = resolver_section[non_data_start:].find("\n\n")
        if section_end == -1:
            non_data_section = resolver_section[non_data_start:]
        else:
            non_data_section = resolver_section[
                non_data_start: non_data_start + section_end
            ]

        actions = []
        for line in non_data_section.split("\n"):
            line = line.strip()
            if line.startswith("android.") or line.startswith("com."):
                actions.append(line)

        return actions

    def execute_adb_shell_command(self, command: str) -> str:
        """Executes an ADB command and returns the output."""
        if command.startswith("adb shell "):
            command = command[10:]
        elif command.startswith("adb "):
            command = command[4:]
        result = self.device.shell(command)
        return result

    def take_screenshot(self) -> None:
        self.device.shell("screencap -p /sdcard/screenshot.png")
        self.device.pull("/sdcard/screenshot.png", "screenshot.png")
        self.device.shell("rm /sdcard/screenshot.png")

        # compressing the ss to avoid "maximum call stack exceeded" error on claude desktop
        with PILImage.open("screenshot.png") as img:
            width, height = img.size
            new_width = int(width * 0.3)
            new_height = int(height * 0.3)
            resized_img = img.resize(
                (new_width, new_height), PILImage.Resampling.LANCZOS
            )

            resized_img.save(
                "compressed_screenshot.png", "PNG", quality=85, optimize=True
            )

    def get_uilayout(self) -> str:
        self.device.shell("uiautomator dump")
        self.device.pull("/sdcard/window_dump.xml", "window_dump.xml")
        self.device.shell("rm /sdcard/window_dump.xml")

        import re
        import xml.etree.ElementTree as ET

        def calculate_center(bounds_str):
            matches = re.findall(r"\[(\d+),(\d+)\]", bounds_str)
            if len(matches) == 2:
                x1, y1 = map(int, matches[0])
                x2, y2 = map(int, matches[1])
                center_x = (x1 + x2) // 2
                center_y = (y1 + y2) // 2
                return center_x, center_y
            return None

        tree = ET.parse("window_dump.xml")
        root = tree.getroot()

        clickable_elements = []
        for element in root.findall(".//node[@clickable='true']"):
            text = element.get("text", "")
            content_desc = element.get("content-desc", "")
            bounds = element.get("bounds", "")

            # Only include elements that have either text or content description
            if text or content_desc:
                center = calculate_center(bounds)
                element_info = "Clickable element:"
                if text:
                    element_info += f"\n  Text: {text}"
                if content_desc:
                    element_info += f"\n  Description: {content_desc}"
                element_info += f"\n  Bounds: {bounds}"
                if center:
                    element_info += f"\n  Center: ({center[0]}, {center[1]})"
                clickable_elements.append(element_info)

        if not clickable_elements:
            return "No clickable elements found with text or description"
        else:
            result = "\n\n".join(clickable_elements)
            return result

```

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

```python
"""
Integration tests for the complete server initialization flow
"""

import os
import sys
import tempfile
from unittest.mock import MagicMock, patch

from adbdevicemanager import AdbDeviceManager

# Add the parent directory to the path so we can import our modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))


class TestServerIntegration:
    """Test complete server initialization scenarios"""

    def setup_method(self):
        """Setup for each test method"""
        self.temp_dir = tempfile.mkdtemp()
        self.config_file = os.path.join(self.temp_dir, "config.yaml")

    def _simulate_server_initialization(self, config_file_path):
        """
        Simulate the complete server initialization process
        Returns: (device_manager, messages)
        """
        import yaml

        messages = []
        config = {}
        device_name = None

        if os.path.exists(config_file_path):
            try:
                with open(config_file_path) as f:
                    config = yaml.safe_load(f.read()) or {}
                device_config = config.get("device", {})
                configured_device_name = device_config.get(
                    "name") if device_config else None

                if configured_device_name and configured_device_name.strip():
                    device_name = configured_device_name.strip()
                    messages.append(f"Loaded config from {config_file_path}")
                    messages.append(f"Configured device: {device_name}")
                else:
                    messages.append(f"Loaded config from {config_file_path}")
                    messages.append(
                        "No device specified in config, will auto-select if only one device connected")
            except Exception as e:
                messages.append(
                    f"Error loading config file {config_file_path}: {e}")
                raise
        else:
            messages.append(
                f"Config file {config_file_path} not found, using auto-selection for device")

        # Initialize device manager (with mocked dependencies)
        device_manager = AdbDeviceManager(device_name, exit_on_error=False)

        return device_manager, messages

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
    @patch('adbdevicemanager.AdbClient')
    def test_no_config_auto_selection_success(self, mock_adb_client, mock_get_devices, mock_check_adb):
        """Test successful server start with no config file and single device"""
        # Setup mocks
        mock_check_adb.return_value = True
        mock_get_devices.return_value = ["device123"]
        mock_device = MagicMock()
        mock_adb_client.return_value.device.return_value = mock_device

        # Use non-existent config file
        non_existent_config = os.path.join(self.temp_dir, "non_existent.yaml")

        with patch('builtins.print') as mock_print:
            device_manager, messages = self._simulate_server_initialization(
                non_existent_config)

        # Verify results
        assert device_manager.device == mock_device
        assert any("not found" in msg for msg in messages)
        assert any("auto-selection" in msg for msg in messages)
        mock_print.assert_called_with(
            "No device specified, automatically selected: device123")

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
    @patch('adbdevicemanager.AdbClient')
    def test_config_with_null_device_auto_selection(self, mock_adb_client, mock_get_devices, mock_check_adb):
        """Test server start with config file containing name: null"""
        # Setup mocks
        mock_check_adb.return_value = True
        mock_get_devices.return_value = ["device456"]
        mock_device = MagicMock()
        mock_adb_client.return_value.device.return_value = mock_device

        # Create config with null device name
        config_content = """
device:
  name: null
"""
        with open(self.config_file, 'w') as f:
            f.write(config_content)

        with patch('builtins.print') as mock_print:
            device_manager, messages = self._simulate_server_initialization(
                self.config_file)

        # Verify results
        assert device_manager.device == mock_device
        assert any("Loaded config" in msg for msg in messages)
        assert any("auto-select" in msg for msg in messages)
        mock_print.assert_called_with(
            "No device specified, automatically selected: device456")

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
    @patch('adbdevicemanager.AdbClient')
    def test_config_with_specific_device(self, mock_adb_client, mock_get_devices, mock_check_adb):
        """Test server start with config file specifying a device"""
        # Setup mocks
        mock_check_adb.return_value = True
        mock_get_devices.return_value = ["device123", "device456"]
        mock_device = MagicMock()
        mock_adb_client.return_value.device.return_value = mock_device

        # Create config with specific device name
        config_content = """
device:
  name: "device456"
"""
        with open(self.config_file, 'w') as f:
            f.write(config_content)

        device_manager, messages = self._simulate_server_initialization(
            self.config_file)

        # Verify results
        assert device_manager.device == mock_device
        mock_adb_client.return_value.device.assert_called_once_with(
            "device456")
        assert any("Configured device: device456" in msg for msg in messages)

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
    def test_multiple_devices_no_config_error(self, mock_get_devices, mock_check_adb):
        """Test server initialization fails with multiple devices and no config"""
        # Setup mocks
        mock_check_adb.return_value = True
        mock_get_devices.return_value = ["device123", "device456"]

        # Use non-existent config file
        non_existent_config = os.path.join(self.temp_dir, "non_existent.yaml")

        try:
            device_manager, messages = self._simulate_server_initialization(
                non_existent_config)
            assert False, "Should have raised an exception"
        except RuntimeError as e:
            assert "Multiple devices connected" in str(e)
            assert "device123" in str(e)
            assert "device456" in str(e)

    @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed')
    @patch('adbdevicemanager.AdbDeviceManager.get_available_devices')
    def test_device_not_found_error(self, mock_get_devices, mock_check_adb):
        """Test server initialization fails when specified device is not found"""
        # Setup mocks
        mock_check_adb.return_value = True
        mock_get_devices.return_value = ["device123"]

        # Create config with non-existent device name
        config_content = """
device:
  name: "non-existent-device"
"""
        with open(self.config_file, 'w') as f:
            f.write(config_content)

        try:
            device_manager, messages = self._simulate_server_initialization(
                self.config_file)
            assert False, "Should have raised an exception"
        except RuntimeError as e:
            assert "Device non-existent-device not found" in str(e)
            assert "Available devices" in str(e)

    def teardown_method(self):
        """Cleanup after each test method"""
        if os.path.exists(self.config_file):
            os.remove(self.config_file)
        os.rmdir(self.temp_dir)

```