├── .python-version ├── src └── android_mcp │ ├── __init__.py │ ├── tree │ ├── __init__.py │ ├── config.py │ ├── utils.py │ ├── views.py │ └── service.py │ ├── mobile │ ├── __init__.py │ ├── views.py │ └── service.py │ └── __main__.py ├── .gitignore ├── pyproject.toml ├── LICENSE ├── CONTRIBUTING.md └── README.md /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /src/android_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/android_mcp/tree/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/android_mcp/mobile/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.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 | notebook.ipynb -------------------------------------------------------------------------------- /src/android_mcp/tree/config.py: -------------------------------------------------------------------------------- 1 | INTERACTIVE_CLASSES = [ 2 | "android.widget.Button", 3 | "android.widget.ImageButton", 4 | "android.widget.EditText", 5 | "android.widget.CheckBox", 6 | "android.widget.Switch", 7 | "android.widget.RadioButton", 8 | "android.widget.Spinner", 9 | "android.widget.SeekBar", 10 | ] 11 | 12 | -------------------------------------------------------------------------------- /src/android_mcp/mobile/views.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from android_mcp.tree.views import TreeState 3 | from PIL.Image import Image 4 | from typing import Literal 5 | 6 | @dataclass 7 | class App: 8 | name:str 9 | status:Literal['Maximized','Minimized'] 10 | 11 | @dataclass 12 | class MobileState: 13 | tree_state:TreeState 14 | screenshot:bytes|str|Image|None -------------------------------------------------------------------------------- /src/android_mcp/tree/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | def extract_cordinates(node): 4 | attributes = node.attrib 5 | bounds=attributes.get('bounds') 6 | match = re.search(r'\[(\d+),(\d+)]\[(\d+),(\d+)]', bounds) 7 | if match: 8 | x1, y1, x2, y2 = map(int, match.groups()) 9 | return x1, y1, x2, y2 10 | 11 | def get_center_cordinates(cordinates:tuple[int,int,int,int]): 12 | x_center,y_center = (cordinates[0]+cordinates[2])//2,(cordinates[1]+cordinates[3])//2 13 | return x_center,y_center -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "android-mcp" 3 | version = "0.1.0" 4 | description = "Lightweight MCP Server for Android Operating System" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | license = { file = "LICENSE" } 8 | urls = { Homepage = "https://github.com/CursorTouch/Android-MCP" } 9 | keywords = ["android", "mcp", "mobile", "automation"] 10 | dependencies = [ 11 | "fastmcp>=2.14.0", 12 | "ipykernel>=6.30.1", 13 | "pillow>=11.2.1", 14 | "tabulate>=0.9.0", 15 | "uiautomator2>=3.3.1", 16 | ] 17 | 18 | [project.scripts] 19 | android-mcp = "android_mcp.__main__:main" 20 | 21 | [build-system] 22 | requires = ["hatchling"] 23 | build-backend = "hatchling.build" 24 | -------------------------------------------------------------------------------- /src/android_mcp/tree/views.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from tabulate import tabulate 3 | 4 | @dataclass 5 | class ElementNode: 6 | name: str 7 | class_name: str 8 | coordinates: 'CenterCord' 9 | bounding_box: 'BoundingBox' 10 | 11 | @dataclass 12 | class BoundingBox: 13 | x1:int 14 | y1:int 15 | x2:int 16 | y2:int 17 | 18 | def to_string(self): 19 | return f'[{self.x1},{self.y1}][{self.x2},{self.y2}]' 20 | 21 | @dataclass 22 | class TreeState: 23 | interactive_elements:list[ElementNode] 24 | 25 | def to_string(self): 26 | data = [[index, node.name, node.class_name, node.coordinates.to_string()] for index, node in enumerate(self.interactive_elements)] 27 | return tabulate(data, headers=["Label", "Name", "Class", "Coordinates"], tablefmt="plain") 28 | 29 | @dataclass 30 | class CenterCord: 31 | x: int 32 | y: int 33 | 34 | def to_string(self): 35 | return f'({self.x},{self.y})' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 JEOMON GEORGE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/android_mcp/__main__.py: -------------------------------------------------------------------------------- 1 | from fastmcp import FastMCP 2 | from fastmcp.utilities.types import Image 3 | from contextlib import asynccontextmanager 4 | from argparse import ArgumentParser 5 | from android_mcp.mobile.service import Mobile 6 | from textwrap import dedent 7 | import asyncio 8 | 9 | parser = ArgumentParser() 10 | parser.add_argument('--emulator',action='store_true',help='Use the emulator') 11 | args = parser.parse_args() 12 | 13 | instructions=dedent(''' 14 | Android MCP server provides tools to interact directly with the Android device, 15 | thus enabling to operate the mobile device like an actual USER.''') 16 | 17 | @asynccontextmanager 18 | async def lifespan(app: FastMCP): 19 | """Runs initialization code before the server starts and cleanup code after it shuts down.""" 20 | await asyncio.sleep(1) # Simulate startup latency 21 | yield 22 | 23 | mcp=FastMCP(name="Android-MCP",instructions=instructions) 24 | 25 | mobile=Mobile(device=None if not args.emulator else 'emulator-5554') 26 | device=mobile.get_device() 27 | 28 | @mcp.tool(name='Click-Tool',description='Click on a specific cordinate') 29 | def click_tool(x:int,y:int): 30 | device.click(x,y) 31 | return f'Clicked on ({x},{y})' 32 | 33 | @mcp.tool('State-Tool',description='Get the state of the device. Optionally includes visual screenshot when use_vision=True.') 34 | def state_tool(use_vision:bool=False): 35 | mobile_state=mobile.get_state(use_vision=use_vision,as_bytes=True) 36 | return [mobile_state.tree_state.to_string()]+([Image(data=mobile_state.screenshot,format='PNG')] if use_vision else []) 37 | 38 | @mcp.tool(name='Long-Click-Tool',description='Long click on a specific cordinate') 39 | def long_click_tool(x:int,y:int): 40 | device.long_click(x,y) 41 | return f'Long Clicked on ({x},{y})' 42 | 43 | @mcp.tool(name='Swipe-Tool',description='Swipe on a specific cordinate') 44 | def swipe_tool(x1:int,y1:int,x2:int,y2:int): 45 | device.swipe(x1,y1,x2,y2) 46 | return f'Swiped from ({x1},{y1}) to ({x2},{y2})' 47 | 48 | @mcp.tool(name='Type-Tool',description='Type on a specific cordinate') 49 | def type_tool(text:str,x:int,y:int,clear:bool=False): 50 | device.set_fastinput_ime(enable=True) 51 | device.send_keys(text=text,clear=clear) 52 | return f'Typed "{text}" on ({x},{y})' 53 | 54 | @mcp.tool(name='Drag-Tool',description='Drag from location and drop on another location') 55 | def drag_tool(x1:int,y1:int,x2:int,y2:int): 56 | device.drag(x1,y1,x2,y2) 57 | return f'Dragged from ({x1},{y1}) and dropped on ({x2},{y2})' 58 | 59 | @mcp.tool(name='Press-Tool',description='Press on specific button on the device') 60 | def press_tool(button:str): 61 | device.press(button) 62 | return f'Pressed the "{button}" button' 63 | 64 | @mcp.tool(name='Notification-Tool',description='Access the notifications seen on the device') 65 | def notification_tool(): 66 | device.open_notification() 67 | return 'Accessed notification bar' 68 | 69 | @mcp.tool(name='Wait-Tool',description='Wait for a specific amount of time') 70 | def wait_tool(duration:int): 71 | device.sleep(duration) 72 | return f'Waited for {duration} seconds' 73 | 74 | def main(): 75 | mcp.run() 76 | 77 | if __name__ == '__main__': 78 | main() -------------------------------------------------------------------------------- /src/android_mcp/mobile/service.py: -------------------------------------------------------------------------------- 1 | from android_mcp.mobile.views import MobileState 2 | from android_mcp.tree.service import Tree 3 | import uiautomator2 as u2 4 | from io import BytesIO 5 | from PIL import Image 6 | import base64 7 | 8 | class Mobile: 9 | def __init__(self,device:str=None): 10 | try: 11 | self.device = u2.connect(device) 12 | self.device.info 13 | except u2.ConnectError as e: 14 | raise ConnectionError(f"Failed to connect to device {device}: {e}") 15 | except Exception as e: 16 | raise RuntimeError(f"Unexpected error connecting to device {device}: {e}") 17 | 18 | def get_device(self): 19 | return self.device 20 | 21 | def get_state(self,use_vision=False,as_bytes:bool=False,as_base64:bool=False): 22 | try: 23 | tree = Tree(self) 24 | tree_state = tree.get_state() 25 | if use_vision: 26 | nodes=tree_state.interactive_elements 27 | annotated_screenshot=tree.annotated_screenshot(nodes=nodes,scale=1.0) 28 | if as_base64: 29 | screenshot=self.as_base64(annotated_screenshot) 30 | elif as_bytes: 31 | screenshot=self.screenshot_in_bytes(annotated_screenshot) 32 | else: 33 | screenshot=annotated_screenshot 34 | else: 35 | screenshot=None 36 | return MobileState(tree_state=tree_state,screenshot=screenshot) 37 | except Exception as e: 38 | raise RuntimeError(f"Failed to get device state: {e}") 39 | 40 | def get_screenshot(self,scale:float=0.7)->Image.Image: 41 | try: 42 | screenshot=self.device.screenshot() 43 | if screenshot is None: 44 | raise ValueError("Screenshot capture returned None.") 45 | size=(screenshot.width*scale, screenshot.height*scale) 46 | screenshot.thumbnail(size=size, resample=Image.Resampling.LANCZOS) 47 | return screenshot 48 | except Exception as e: 49 | raise RuntimeError(f"Failed to get screenshot: {e}") 50 | 51 | def screenshot_in_bytes(self,screenshot:Image.Image)->bytes: 52 | try: 53 | if screenshot is None: 54 | raise ValueError("Screenshot is None") 55 | io=BytesIO() 56 | screenshot.save(io,format='PNG') 57 | bytes=io.getvalue() 58 | if len(bytes) == 0: 59 | raise ValueError("Screenshot conversion resulted in empty bytes.") 60 | return bytes 61 | except Exception as e: 62 | raise RuntimeError(f"Failed to convert screenshot to bytes: {e}") 63 | 64 | def as_base64(self,screenshot:Image.Image)->str: 65 | try: 66 | if screenshot is None: 67 | raise ValueError("Screenshot is None") 68 | io=BytesIO() 69 | screenshot.save(io,format='PNG') 70 | bytes=io.getvalue() 71 | if len(bytes) == 0: 72 | raise ValueError("Screenshot conversion resulted in empty bytes.") 73 | return base64.b64encode(bytes).decode('utf-8') 74 | except Exception as e: 75 | raise RuntimeError(f"Failed to convert screenshot to base64: {e}") 76 | 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Android-MCP 2 | 3 | Thank you for your interest in contributing to MCP-Use! This document provides guidelines and instructions for contributing to this project. 4 | 5 | ## Table of Contents 6 | 7 | - [Getting Started](#getting-started) 8 | - [Development Environment](#development-environment) 9 | - [Installation from Source](#installation-from-source) 10 | - [Development Workflow](#development-workflow) 11 | - [Branching Strategy](#branching-strategy) 12 | - [Commit Messages](#commit-messages) 13 | - [Code Style](#code-style) 14 | - [Pre-commit Hooks](#pre-commit-hooks) 15 | - [Testing](#testing) 16 | - [Running Tests](#running-tests) 17 | - [Adding Tests](#adding-tests) 18 | - [Pull Requests](#pull-requests) 19 | - [Creating a Pull Request](#creating-a-pull-request) 20 | - [Pull Request Template](#pull-request-template) 21 | - [Documentation](#documentation) 22 | - [Release Process](#release-process) 23 | - [Getting Help](#getting-help) 24 | 25 | ## Getting Started 26 | 27 | ### Development Environment 28 | 29 | Android-MCP requires: 30 | - Python 3.11 or later 31 | 32 | ### Installation from Source 33 | 34 | 1. Fork the repository on GitHub. 35 | 2. Clone your fork locally: 36 | 37 | ```bash 38 | git clone https://github.com/Jeomon/Windows-MCP.git 39 | cd Android-MCP 40 | ``` 41 | 42 | 3. Install the package in development mode: 43 | 44 | ```bash 45 | pip install -e ".[dev,search]" 46 | ``` 47 | 48 | 4. Set up pre-commit hooks: 49 | 50 | ```bash 51 | pip install pre-commit 52 | pre-commit install 53 | ``` 54 | 55 | ## Development Workflow 56 | 57 | ### Branching Strategy 58 | 59 | - `main` branch contains the latest stable code 60 | - Create feature branches from `main` named according to the feature you're implementing: `feature/your-feature-name` 61 | - For bug fixes, use: `fix/bug-description` 62 | 63 | ### Commit Messages 64 | 65 | For now no commit style is enforced, try to keep your commit messages informational. 66 | 67 | ### Code Style 68 | 69 | Key style guidelines: 70 | 71 | - Line length: 100 characters 72 | - Use double quotes for strings 73 | - Follow PEP 8 naming conventions 74 | - Add type hints to function signatures 75 | 76 | ### Pre-commit Hooks 77 | 78 | We use pre-commit hooks to ensure code quality before committing. The configuration is in `.pre-commit-config.yaml`. 79 | 80 | The hooks will: 81 | 82 | - Run linting checks 83 | - Check for trailing whitespace and fix it 84 | - Ensure files end with a newline 85 | - Validate YAML files 86 | - Check for large files 87 | - Remove debug statements 88 | 89 | ## Testing 90 | 91 | ### Running Tests 92 | 93 | Run the test suite with pytest: 94 | 95 | ```bash 96 | pytest 97 | ``` 98 | 99 | To run specific test categories: 100 | 101 | ```bash 102 | pytest tests/ 103 | ``` 104 | 105 | ### Adding Tests 106 | 107 | - Add unit tests for new functionality in `tests/unit/` 108 | - For slow or network-dependent tests, mark them with `@pytest.mark.slow` or `@pytest.mark.integration` 109 | - Aim for high test coverage of new code 110 | 111 | ## Pull Requests 112 | 113 | ### Creating a Pull Request 114 | 115 | 1. Ensure your code passes all tests and pre-commit hooks 116 | 2. Push your changes to your fork 117 | 3. Submit a pull request to the main repository 118 | 4. Follow the pull request template 119 | 120 | ## Documentation 121 | 122 | - Update docstrings for new or modified functions, classes, and methods 123 | - Use Google-style docstrings: 124 | 125 | ```python 126 | def function_name(param1: type, param2: type) -> return_type: 127 | """Short description. 128 | Longer description if needed. 129 | 130 | Args: 131 | param1: Description of param1 132 | param2: Description of param2 133 | 134 | Returns: 135 | Description of return value 136 | 137 | Raises: 138 | ExceptionType: When and why this exception is raised 139 | """ 140 | ``` 141 | 142 | - Update README.md for user-facing changes 143 | 144 | ## Getting Help 145 | 146 | If you need help with your contribution: 147 | 148 | - Open an issue for discussion 149 | - Reach out to the maintainers 150 | - Check existing code for examples 151 | 152 | Thank you for contributing to Android-MCP! -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
20 | 21 |