uiautomator2-mcp-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- u2mcp/__init__.py +0 -0
- u2mcp/__main__.py +63 -0
- u2mcp/mcp.py +18 -0
- u2mcp/py.typed +0 -0
- u2mcp/tools/__init__.py +3 -0
- u2mcp/tools/action.py +169 -0
- u2mcp/tools/app.py +231 -0
- u2mcp/tools/device.py +290 -0
- uiautomator2_mcp_server-0.1.0.dist-info/METADATA +11 -0
- uiautomator2_mcp_server-0.1.0.dist-info/RECORD +12 -0
- uiautomator2_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- uiautomator2_mcp_server-0.1.0.dist-info/entry_points.txt +3 -0
u2mcp/__init__.py
ADDED
|
File without changes
|
u2mcp/__main__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
level=logging.INFO,
|
|
11
|
+
format="[%(asctime)s] %(levelname)s %(name)s - %(message)s",
|
|
12
|
+
handlers=[logging.StreamHandler()],
|
|
13
|
+
force=True,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
logging.getLogger("mcp.server").setLevel(logging.WARNING)
|
|
17
|
+
logging.getLogger("sse_starlette").setLevel(logging.WARNING)
|
|
18
|
+
logging.getLogger("docket").setLevel(logging.WARNING)
|
|
19
|
+
logging.getLogger("fakeredis").setLevel(logging.WARNING)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Transport(StrEnum):
|
|
23
|
+
streamable_http = "streamable-http"
|
|
24
|
+
stdio = "stdio"
|
|
25
|
+
# http = "http"
|
|
26
|
+
# sse = "sse"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(
|
|
30
|
+
transport: Annotated[
|
|
31
|
+
Transport, typer.Option("--transport", "-f", help="The transport mechanisms for client-server communication")
|
|
32
|
+
] = Transport.streamable_http,
|
|
33
|
+
host: Annotated[str | None, typer.Option("--host", "-H", show_default=False, help="Host address for http mode")] = None,
|
|
34
|
+
port: Annotated[int | None, typer.Option("--port", "-p", show_default=False, help="Port number for http mode")] = None,
|
|
35
|
+
):
|
|
36
|
+
"""Run mcp server
|
|
37
|
+
Args:
|
|
38
|
+
transport (Literal["http", "stdio"]): transport type
|
|
39
|
+
host (str | None): host
|
|
40
|
+
port (int | None): port
|
|
41
|
+
"""
|
|
42
|
+
from . import tools as _
|
|
43
|
+
from .mcp import mcp
|
|
44
|
+
|
|
45
|
+
if transport == Transport.stdio:
|
|
46
|
+
mcp.run(transport.value)
|
|
47
|
+
elif transport == Transport.streamable_http:
|
|
48
|
+
transport_kwargs = {}
|
|
49
|
+
if host:
|
|
50
|
+
transport_kwargs["host"] = host
|
|
51
|
+
if port:
|
|
52
|
+
transport_kwargs["port"] = port
|
|
53
|
+
mcp.run(transport.value, **transport_kwargs)
|
|
54
|
+
else:
|
|
55
|
+
typer.Abort(f"Unknown transport: {transport}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main():
|
|
59
|
+
typer.run(run)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
u2mcp/mcp.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This MCP server provides tools for controlling and interacting with Android devices using uiautomator2.
|
|
3
|
+
|
|
4
|
+
It allows you to perform various operations on Android devices such as connecting to devices, taking screenshots,
|
|
5
|
+
getting device information, accessing UI hierarchy, tap on screens, and more...
|
|
6
|
+
|
|
7
|
+
It also provides tools for managing Android applications, such as installing, uninstalling, starting, stopping, and clearing applications.
|
|
8
|
+
|
|
9
|
+
Before performing operations on a device, you need to initialize it using the init tool.
|
|
10
|
+
|
|
11
|
+
All operations require a device serial number to identify the target device.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from fastmcp import FastMCP
|
|
15
|
+
|
|
16
|
+
__all__ = ["mcp"]
|
|
17
|
+
|
|
18
|
+
mcp = FastMCP(name="uiautomator2", instructions=__doc__)
|
u2mcp/py.typed
ADDED
|
File without changes
|
u2mcp/tools/__init__.py
ADDED
u2mcp/tools/action.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from ..mcp import mcp
|
|
6
|
+
from .device import get_device
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"click",
|
|
10
|
+
"long_click",
|
|
11
|
+
"double_click",
|
|
12
|
+
"swipe",
|
|
13
|
+
"swipe_points",
|
|
14
|
+
"drag",
|
|
15
|
+
"press_key",
|
|
16
|
+
"send_text",
|
|
17
|
+
"clear_text",
|
|
18
|
+
"screen_on",
|
|
19
|
+
"screen_off",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@mcp.tool("click")
|
|
24
|
+
async def click(serial: str, x: int, y: int):
|
|
25
|
+
"""Click at specific coordinates
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
serial (str): Android device serialno
|
|
29
|
+
x (int): X coordinate
|
|
30
|
+
y (int): Y coordinate
|
|
31
|
+
"""
|
|
32
|
+
async with get_device(serial) as device:
|
|
33
|
+
await asyncio.to_thread(device.click, x, y)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@mcp.tool("long_click")
|
|
37
|
+
async def long_click(serial: str, x: int, y: int, duration: float = 0.5):
|
|
38
|
+
"""Long click at specific coordinates
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
serial (str): Android device serialno
|
|
42
|
+
x (int): X coordinate
|
|
43
|
+
y (int): Y coordinate
|
|
44
|
+
duration (float): Duration of the long click in seconds, default is 0.5
|
|
45
|
+
"""
|
|
46
|
+
async with get_device(serial) as device:
|
|
47
|
+
await asyncio.to_thread(device.long_click, x, y, duration)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@mcp.tool("double_click")
|
|
51
|
+
async def double_click(serial: str, x: int, y: int, duration: float = 0.1):
|
|
52
|
+
"""Double click at specific coordinates
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
serial (str): Android device serialno
|
|
56
|
+
x (int): X coordinate
|
|
57
|
+
y (int): Y coordinate
|
|
58
|
+
duration (float): Duration between clicks in seconds, default is 0.1
|
|
59
|
+
"""
|
|
60
|
+
async with get_device(serial) as device:
|
|
61
|
+
await asyncio.to_thread(device.double_click, x, y, duration)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@mcp.tool("swipe")
|
|
65
|
+
async def swipe(serial: str, fx: int, fy: int, tx: int, ty: int, duration: float = 0.0, step: int = 0):
|
|
66
|
+
"""Swipe from one point to another
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
serial (str): Android device serialno
|
|
70
|
+
fx (int): From position X coordinate
|
|
71
|
+
fy (int): From position Y coordinate
|
|
72
|
+
tx (int): To position X coordinate
|
|
73
|
+
ty (int): To position Y coordinate
|
|
74
|
+
duration (float): duration
|
|
75
|
+
steps: 1 steps is about 5ms, if set, duration will be ignore
|
|
76
|
+
"""
|
|
77
|
+
async with get_device(serial) as device:
|
|
78
|
+
await asyncio.to_thread(device.swipe, fx, fy, tx, ty, duration if duration > 0 else None, step if step > 0 else None)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@mcp.tool("swipe_points")
|
|
82
|
+
async def swipe_points(serial: str, points: list[tuple[int, int]], duration: float = 0.5):
|
|
83
|
+
"""Swipe through multiple points
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
serial (str): Android device serialno
|
|
87
|
+
points (list[tuple[int, int]]): List of (x, y) coordinates to swipe through
|
|
88
|
+
duration (float): Duration of swipe in seconds, default is 0.5
|
|
89
|
+
"""
|
|
90
|
+
async with get_device(serial) as device:
|
|
91
|
+
await asyncio.to_thread(device.swipe_points, points, duration)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@mcp.tool("drag")
|
|
95
|
+
async def drag(serial: str, sx: int, sy: int, ex: int, ey: int, duration: float = 0.5):
|
|
96
|
+
"""Swipe from one point to another point.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
serial (str): Android device serialno
|
|
100
|
+
sx (int): Start X coordinate
|
|
101
|
+
sy (int): Start Y coordinate
|
|
102
|
+
ex (int): End X coordinate
|
|
103
|
+
ey (int): End Y coordinate
|
|
104
|
+
duration (float): Duration of drag in seconds, default is 0.5
|
|
105
|
+
"""
|
|
106
|
+
async with get_device(serial) as device:
|
|
107
|
+
await asyncio.to_thread(device.drag, sx, sy, ex, ey, duration)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@mcp.tool("press_key")
|
|
111
|
+
async def press_key(serial: str, key: str):
|
|
112
|
+
"""Press a key
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
serial (str): Android device serialno
|
|
116
|
+
key (str): Key to press.
|
|
117
|
+
Supported key name includes:
|
|
118
|
+
home, back, left, right, up, down, center, menu, search, enter,
|
|
119
|
+
delete(or del), recent(recent apps), volume_up, volume_down,
|
|
120
|
+
volume_mute, camera, power
|
|
121
|
+
"""
|
|
122
|
+
async with get_device(serial) as device:
|
|
123
|
+
await asyncio.to_thread(device.press, key)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@mcp.tool("send_text")
|
|
127
|
+
async def send_text(serial: str, text: str, clear: bool = False):
|
|
128
|
+
"""Send text to the current input field
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
serial (str): Android device serialno
|
|
132
|
+
text (str): input text
|
|
133
|
+
clear: clear text before input
|
|
134
|
+
"""
|
|
135
|
+
async with get_device(serial) as device:
|
|
136
|
+
await asyncio.to_thread(device.send_keys, text, clear)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@mcp.tool("clear_text")
|
|
140
|
+
async def clear_text(serial: str):
|
|
141
|
+
"""Clear text in the current input field
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
serial (str): Android device serialno
|
|
145
|
+
"""
|
|
146
|
+
async with get_device(serial) as device:
|
|
147
|
+
await asyncio.to_thread(device.clear_text)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@mcp.tool("screen_on")
|
|
151
|
+
async def screen_on(serial: str):
|
|
152
|
+
"""Turn screen on
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
serial (str): Android device serialno
|
|
156
|
+
"""
|
|
157
|
+
async with get_device(serial) as device:
|
|
158
|
+
await asyncio.to_thread(device.screen_on)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@mcp.tool("screen_off")
|
|
162
|
+
async def screen_off(serial: str):
|
|
163
|
+
"""Turn screen off
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
serial (str): Android device serialno
|
|
167
|
+
"""
|
|
168
|
+
async with get_device(serial) as device:
|
|
169
|
+
await asyncio.to_thread(device.screen_off)
|
u2mcp/tools/app.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..mcp import mcp
|
|
7
|
+
from .device import get_device
|
|
8
|
+
|
|
9
|
+
__all__ = (
|
|
10
|
+
"app_install",
|
|
11
|
+
"app_uninstall",
|
|
12
|
+
"app_uninstall_all",
|
|
13
|
+
"app_start",
|
|
14
|
+
"app_stop",
|
|
15
|
+
"app_stop_all",
|
|
16
|
+
"app_clear",
|
|
17
|
+
"app_info",
|
|
18
|
+
"app_current",
|
|
19
|
+
"app_list",
|
|
20
|
+
"app_list_running",
|
|
21
|
+
"app_auto_grant_permissions",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@mcp.tool("app_install")
|
|
26
|
+
async def app_install(serial: str, data: str):
|
|
27
|
+
"""Install app
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
serial (str): Android device serialno
|
|
31
|
+
data (str): APK file path or url
|
|
32
|
+
"""
|
|
33
|
+
async with get_device(serial) as device:
|
|
34
|
+
await asyncio.to_thread(device.app_install, data)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@mcp.tool("app_uninstall")
|
|
38
|
+
async def app_uninstall(serial: str, package_name: str) -> bool:
|
|
39
|
+
"""Uninstall an app
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
serial (str): Android device serialno
|
|
43
|
+
package_name (str): package name
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
bool: success
|
|
47
|
+
"""
|
|
48
|
+
async with get_device(serial) as device:
|
|
49
|
+
return await asyncio.to_thread(device.app_uninstall, package_name)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@mcp.tool("app_uninstall_all")
|
|
53
|
+
async def app_uninstall_all(serial: str, excludes: list[str] | None = None) -> list[str]:
|
|
54
|
+
"""Uninstall all apps
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
serial (str): Android device serialno
|
|
58
|
+
excludes (list[str] | None): packages that do not want to uninstall
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
list[str]: list of uninstalled apps
|
|
62
|
+
"""
|
|
63
|
+
async with get_device(serial) as device:
|
|
64
|
+
return await asyncio.to_thread(device.app_uninstall_all, excludes or [])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@mcp.tool("app_start")
|
|
68
|
+
async def app_start(
|
|
69
|
+
serial: str,
|
|
70
|
+
package_name: str,
|
|
71
|
+
activity: str | None = None,
|
|
72
|
+
wait: bool = False,
|
|
73
|
+
stop: bool = False,
|
|
74
|
+
):
|
|
75
|
+
"""Launch application
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
serial (str): Android device serialno
|
|
79
|
+
package_name (str): package name
|
|
80
|
+
activity (str): app activity
|
|
81
|
+
stop (bool): Stop app before starting the activity. (require activity)
|
|
82
|
+
wait (bool): wait until app started. default False
|
|
83
|
+
"""
|
|
84
|
+
async with get_device(serial) as device:
|
|
85
|
+
await asyncio.to_thread(device.app_start, package_name, activity, wait, stop)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@mcp.tool("app_wait")
|
|
89
|
+
async def app_wait(serial: str, package_name: str, timeout: float = 20.0, front=False):
|
|
90
|
+
"""Wait until app launched
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
serial (str): Android device serialno
|
|
94
|
+
package_name (str): package name
|
|
95
|
+
timeout (float): maximum wait time seconds
|
|
96
|
+
front (bool): wait until app is current app
|
|
97
|
+
"""
|
|
98
|
+
async with get_device(serial) as device:
|
|
99
|
+
if not await asyncio.to_thread(device.app_wait, package_name, timeout, front):
|
|
100
|
+
raise RuntimeError(f"Failed to wait App {package_name} to launch")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@mcp.tool("app_stop")
|
|
104
|
+
async def app_stop(serial: str, package_name: str):
|
|
105
|
+
"""Stop one application
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
serial (str): Android device serialno
|
|
109
|
+
package_name (str): package name
|
|
110
|
+
"""
|
|
111
|
+
async with get_device(serial) as device:
|
|
112
|
+
await asyncio.to_thread(device.app_stop, package_name)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@mcp.tool("app_stop_all")
|
|
116
|
+
async def app_stop_all(serial: str, excludes: list[str] | None = None) -> list[str]:
|
|
117
|
+
"""Stop all third party applications
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
excludes (list): apps that do now want to kill
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
list[str]: a list of killed apps
|
|
124
|
+
"""
|
|
125
|
+
async with get_device(serial) as device:
|
|
126
|
+
return await asyncio.to_thread(device.app_stop_all, excludes or [])
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@mcp.tool("app_clear")
|
|
130
|
+
async def app_clear(serial: str, package_name: str):
|
|
131
|
+
"""Stop and clear app data: pm clear
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
serial (str): Android device serialno
|
|
135
|
+
package_name (str): package name
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
bool: success
|
|
139
|
+
"""
|
|
140
|
+
async with get_device(serial) as device:
|
|
141
|
+
await asyncio.to_thread(device.app_clear, package_name)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@mcp.tool("app_info")
|
|
145
|
+
async def app_info(serial: str, package_name: str) -> dict[str, Any]:
|
|
146
|
+
"""
|
|
147
|
+
Get app info
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
serial (str): Android device serialno
|
|
151
|
+
package_name (str): package name
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
dict[str,Any]: app info
|
|
155
|
+
|
|
156
|
+
Example:
|
|
157
|
+
{"versionName": "1.1.7", "versionCode": 1001007}
|
|
158
|
+
"""
|
|
159
|
+
async with get_device(serial) as device:
|
|
160
|
+
return await asyncio.to_thread(device.app_info, package_name)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@mcp.tool("app_current")
|
|
164
|
+
async def app_current(serial: str) -> dict[str, Any]:
|
|
165
|
+
"""
|
|
166
|
+
Get current app info
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
serial (str): Android device serialno
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
dict[str,Any]: running app info
|
|
173
|
+
"""
|
|
174
|
+
async with get_device(serial) as device:
|
|
175
|
+
return await asyncio.to_thread(device.app_current)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@mcp.tool("app_list")
|
|
179
|
+
async def app_list(serial: str, filter: str = "") -> list[str]:
|
|
180
|
+
"""
|
|
181
|
+
List installed app package names
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
serial (str): Android device serialno
|
|
185
|
+
filter (str): [-f] [-d] [-e] [-s] [-3] [-i] [-u] [--user USER_ID] [FILTER]
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
list[str]: list of apps by filter
|
|
189
|
+
"""
|
|
190
|
+
async with get_device(serial) as device:
|
|
191
|
+
return await asyncio.to_thread(device.app_list, filter.strip())
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@mcp.tool("app_list_running")
|
|
195
|
+
async def app_list_running(serial: str) -> list[str]:
|
|
196
|
+
"""
|
|
197
|
+
List running apps
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
serial (str): Android device serialno
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
list[str]: list of running apps
|
|
204
|
+
"""
|
|
205
|
+
async with get_device(serial) as device:
|
|
206
|
+
return await asyncio.to_thread(device.app_list_running)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@mcp.tool("app_auto_grant_permissions")
|
|
210
|
+
async def app_auto_grant_permissions(serial: str, package_name: str):
|
|
211
|
+
"""auto grant permissions
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
serial (str): Android device serialno
|
|
215
|
+
package_name (str): package name
|
|
216
|
+
|
|
217
|
+
Help of "adb shell pm":
|
|
218
|
+
grant [--user USER_ID] PACKAGE PERMISSION
|
|
219
|
+
revoke [--user USER_ID] PACKAGE PERMISSION
|
|
220
|
+
These commands either grant or revoke permissions to apps. The permissions
|
|
221
|
+
must be declared as used in the app's manifest, be runtime permissions
|
|
222
|
+
(protection level dangerous), and the app targeting SDK greater than Lollipop MR1 (API level 22).
|
|
223
|
+
|
|
224
|
+
Help of "Android official pm" see <https://developer.android.com/tools/adb#pm>
|
|
225
|
+
Grant a permission to an app. On devices running Android 6.0 (API level 23) and higher,
|
|
226
|
+
the permission can be any permission declared in the app manifest.
|
|
227
|
+
On devices running Android 5.1 (API level 22) and lower,
|
|
228
|
+
must be an optional permission defined by the app.
|
|
229
|
+
"""
|
|
230
|
+
async with get_device(serial) as device:
|
|
231
|
+
await asyncio.to_thread(device.app_auto_grant_permissions, package_name)
|
u2mcp/tools/device.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from base64 import b64encode
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import uiautomator2 as u2
|
|
11
|
+
from adbutils import adb
|
|
12
|
+
from fastmcp.dependencies import CurrentContext
|
|
13
|
+
from fastmcp.server.context import Context
|
|
14
|
+
from fastmcp.utilities.logging import get_logger
|
|
15
|
+
from PIL.Image import Image
|
|
16
|
+
|
|
17
|
+
from ..mcp import mcp
|
|
18
|
+
|
|
19
|
+
__all__ = (
|
|
20
|
+
"device_list",
|
|
21
|
+
"init",
|
|
22
|
+
"connect",
|
|
23
|
+
"disconnect",
|
|
24
|
+
"disconnect_all",
|
|
25
|
+
"window_size",
|
|
26
|
+
"screenshot",
|
|
27
|
+
"dump_hierarchy",
|
|
28
|
+
"info",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
StdoutType = Literal["stdout", "stderr"]
|
|
33
|
+
|
|
34
|
+
_devices: dict[str, tuple[asyncio.Semaphore, u2.Device]] = {}
|
|
35
|
+
_device_connect_lock = asyncio.Lock()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@asynccontextmanager
|
|
39
|
+
async def get_device(serial: str):
|
|
40
|
+
async with _device_connect_lock:
|
|
41
|
+
try:
|
|
42
|
+
semaphore, device = _devices[serial]
|
|
43
|
+
except KeyError:
|
|
44
|
+
|
|
45
|
+
def _connect():
|
|
46
|
+
_d = u2.connect(serial)
|
|
47
|
+
_d.info
|
|
48
|
+
return _d
|
|
49
|
+
|
|
50
|
+
device = await asyncio.to_thread(_connect)
|
|
51
|
+
semaphore = asyncio.Semaphore()
|
|
52
|
+
_devices[serial] = semaphore, device
|
|
53
|
+
async with semaphore:
|
|
54
|
+
yield device
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@mcp.tool("device_list")
|
|
58
|
+
async def device_list() -> list[dict[str, Any]]:
|
|
59
|
+
device_list = await asyncio.to_thread(adb.device_list)
|
|
60
|
+
return [d.info for d in device_list]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@mcp.tool("init")
|
|
64
|
+
async def init(serial: str = "", ctx: Context = CurrentContext()):
|
|
65
|
+
"""Install essential resources to device.
|
|
66
|
+
|
|
67
|
+
Important:
|
|
68
|
+
This tool must be run on the Android device before running operation actions.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
serial (str): Android device serialno to initialize. If empty string, all devices will be initialized.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
None upon successful completion (exit code 0).
|
|
75
|
+
Raises an exception if the subprocess returns a non-zero exit code.
|
|
76
|
+
"""
|
|
77
|
+
logger = get_logger(f"{__name__}.init")
|
|
78
|
+
args = ["-m", "uiautomator2", "init"]
|
|
79
|
+
if serial := serial.strip():
|
|
80
|
+
args.extend(["--serial", serial])
|
|
81
|
+
|
|
82
|
+
logger.info("Running uiautomator2 init command: %s %s", sys.executable, args)
|
|
83
|
+
process = await asyncio.create_subprocess_exec(
|
|
84
|
+
sys.executable, *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
85
|
+
)
|
|
86
|
+
if process.stdout is None:
|
|
87
|
+
raise RuntimeError("stdout is None")
|
|
88
|
+
if process.stderr is None:
|
|
89
|
+
raise RuntimeError("stderr is None")
|
|
90
|
+
|
|
91
|
+
output_queue: asyncio.Queue[tuple[StdoutType, str]] = asyncio.Queue()
|
|
92
|
+
|
|
93
|
+
async def stream_subprocess(stream: asyncio.streams.StreamReader, tag: StdoutType):
|
|
94
|
+
while True:
|
|
95
|
+
line_bytes = await stream.readline()
|
|
96
|
+
await output_queue.put((tag, line_bytes.decode()))
|
|
97
|
+
if not line_bytes: # Reached EOF, the empty string is in the queue
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
# Start the stream reading tasks
|
|
101
|
+
tasks = [
|
|
102
|
+
asyncio.create_task(coro)
|
|
103
|
+
for coro in (
|
|
104
|
+
stream_subprocess(process.stdout, "stdout"),
|
|
105
|
+
stream_subprocess(process.stderr, "stderr"),
|
|
106
|
+
)
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
completed_streams = 0
|
|
110
|
+
|
|
111
|
+
logger.info("read uiautomator2 init command stdio")
|
|
112
|
+
|
|
113
|
+
while True:
|
|
114
|
+
tag, line = await output_queue.get()
|
|
115
|
+
|
|
116
|
+
if not line: # This was the EOF sentinel (empty string)
|
|
117
|
+
completed_streams += 1
|
|
118
|
+
if completed_streams == len(tasks):
|
|
119
|
+
output_queue.task_done()
|
|
120
|
+
break # Both streams are done, exit the main consumer loop
|
|
121
|
+
|
|
122
|
+
# Process the actual line data
|
|
123
|
+
if line := line.strip():
|
|
124
|
+
logger.info("%s: %s", tag, line)
|
|
125
|
+
if tag == "stdout":
|
|
126
|
+
await ctx.info(line)
|
|
127
|
+
else:
|
|
128
|
+
await ctx.warning(line)
|
|
129
|
+
|
|
130
|
+
output_queue.task_done()
|
|
131
|
+
|
|
132
|
+
# Wait for the tasks to formally complete and the process to exit
|
|
133
|
+
logger.info("waiting for uiautomator2 init command to complete")
|
|
134
|
+
await asyncio.gather(*tasks)
|
|
135
|
+
exit_code = await process.wait()
|
|
136
|
+
logger.info("uiautomator2 init command exited with code: %s", exit_code)
|
|
137
|
+
if exit_code != 0:
|
|
138
|
+
raise RuntimeError(f"uiautomator2 init command exited with non-zero code: {exit_code}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@mcp.tool("connect")
|
|
142
|
+
async def connect(serial: str = ""):
|
|
143
|
+
"""Connect to an Android device
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
serial (str): Android device serial number. If empty string, connects to the unique device if only one device is connected.
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
dict[str,Any]: Device information
|
|
151
|
+
"""
|
|
152
|
+
global _devices
|
|
153
|
+
device: u2.Device | None = None
|
|
154
|
+
|
|
155
|
+
logger = get_logger(f"{__name__}.connect")
|
|
156
|
+
|
|
157
|
+
if serial := serial.strip():
|
|
158
|
+
try:
|
|
159
|
+
async with get_device(serial) as device:
|
|
160
|
+
# Found, then check if it's still connected
|
|
161
|
+
try:
|
|
162
|
+
return await asyncio.to_thread(lambda: device.device_info | device.info)
|
|
163
|
+
except u2.ConnectError as e:
|
|
164
|
+
# Found, but not connected, delete it
|
|
165
|
+
logger.warning("Device %s is no longer connected, delete it!", serial)
|
|
166
|
+
del _devices[serial]
|
|
167
|
+
raise e from None
|
|
168
|
+
except KeyError:
|
|
169
|
+
# Not found, need a new connection!
|
|
170
|
+
logger.info("Cannot find device with serial %s, connecting...")
|
|
171
|
+
|
|
172
|
+
# make new connection here!
|
|
173
|
+
async with _device_connect_lock:
|
|
174
|
+
device = await asyncio.to_thread(u2.connect, serial)
|
|
175
|
+
logger.info("Connected to device %s", device.serial)
|
|
176
|
+
result = await asyncio.to_thread(lambda: device.device_info | device.info)
|
|
177
|
+
_devices[device.serial] = asyncio.Semaphore(), device
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@mcp.tool("disconnect")
|
|
182
|
+
async def disconnect(serial: str):
|
|
183
|
+
"""Disconnect from an Android device
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
serial (str): Android device serialno
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
None
|
|
190
|
+
"""
|
|
191
|
+
if not (serial := serial.strip()):
|
|
192
|
+
raise ValueError("serial cannot be empty")
|
|
193
|
+
async with _device_connect_lock:
|
|
194
|
+
del _devices[serial]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@mcp.tool("disconnect_all")
|
|
198
|
+
async def disconnect_all():
|
|
199
|
+
"""Disconnect from all Android devices"""
|
|
200
|
+
async with _device_connect_lock:
|
|
201
|
+
_devices.clear()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@mcp.tool("window_size")
|
|
205
|
+
async def window_size(serial: str) -> dict[str, int]:
|
|
206
|
+
"""Get window size of an Android device
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
serial (str): Android device serialno
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
dict[str,int]: Window size object:
|
|
213
|
+
- "width" (int): Window width
|
|
214
|
+
- "height" (int): Window height
|
|
215
|
+
"""
|
|
216
|
+
async with get_device(serial) as device:
|
|
217
|
+
width, height = await asyncio.to_thread(device.window_size)
|
|
218
|
+
return {"width": width, "height": height}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@mcp.tool("screenshot")
|
|
222
|
+
async def screenshot(serial: str, display_id: int = -1) -> dict[str, Any]:
|
|
223
|
+
"""
|
|
224
|
+
Take screenshot of device
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
serial (str): Android device serialno
|
|
228
|
+
display_id (int): use specific display if device has multiple screen. Defaults to -1.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
dict[str,Any]: Screenshot image JPEG data with the following keys:
|
|
232
|
+
- "image" (str): Base64 encoded image data in data URL format (data:image/jpeg;base64,...)
|
|
233
|
+
- "size" (tuple[int,int]): Image dimensions as (width, height)
|
|
234
|
+
"""
|
|
235
|
+
display_id = int(display_id)
|
|
236
|
+
async with get_device(serial) as device:
|
|
237
|
+
im = await asyncio.to_thread(
|
|
238
|
+
device.screenshot,
|
|
239
|
+
display_id=display_id if display_id >= 0 else None,
|
|
240
|
+
) # type: ignore[arg-type]
|
|
241
|
+
|
|
242
|
+
if not isinstance(im, Image):
|
|
243
|
+
raise RuntimeError("Invalid image")
|
|
244
|
+
|
|
245
|
+
with BytesIO() as fp:
|
|
246
|
+
im.save(fp, "jpeg")
|
|
247
|
+
im_data = fp.getvalue()
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
"width": im.width,
|
|
251
|
+
"height": im.height,
|
|
252
|
+
"image": "data:image/jpeg;base64," + b64encode(im_data).decode(),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@mcp.tool("dump_hierarchy")
|
|
257
|
+
async def dump_hierarchy(serial: str, compressed: bool = False, pretty: bool = False, max_depth: int = -1) -> str:
|
|
258
|
+
"""
|
|
259
|
+
Dump window hierarchy
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
serial (str): Android device serialno
|
|
263
|
+
compressed (bool): return compressed xml
|
|
264
|
+
pretty (bool): pretty print xml
|
|
265
|
+
max_depth (int): max depth of hierarchy
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
str: xml string of the hierarchy tree
|
|
269
|
+
"""
|
|
270
|
+
async with get_device(serial) as device:
|
|
271
|
+
return await asyncio.to_thread(
|
|
272
|
+
device.dump_hierarchy, compressed=compressed, pretty=pretty, max_depth=max_depth if max_depth > 0 else None
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@mcp.tool("info")
|
|
277
|
+
async def info(serial: str) -> dict[str, Any]:
|
|
278
|
+
"""
|
|
279
|
+
Get device info
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
serial (str): Android device serialno
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
dict[str,Any]: Device info
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
async with get_device(serial) as device:
|
|
289
|
+
return await asyncio.to_thread(lambda: device.info)
|
|
290
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: uiautomator2-mcp-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: tanbro
|
|
6
|
+
Author-email: tanbro <tanbro@163.com>
|
|
7
|
+
Requires-Dist: fastmcp>=2.14.2,<3.0
|
|
8
|
+
Requires-Dist: uiautomator2>=3.5.0,<4.0
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
u2mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
u2mcp/__main__.py,sha256=-Vd3rfbMDPvY-TNgyntRRG-_cMj7kxgsag_df3gAe_w,1769
|
|
3
|
+
u2mcp/mcp.py,sha256=j8b_G2d8QX6VdjsUqwmeD1Eu9Sc79BlBWvKAmhTQOK8,721
|
|
4
|
+
u2mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
u2mcp/tools/__init__.py,sha256=tKfsoF6tYQSapfGC1ybEcU4S77cipyfO5tYR6u0eAlI,66
|
|
6
|
+
u2mcp/tools/action.py,sha256=A9dCED1NiKOJpw9lBewOQ9YQvfGOkK8bEL1L9FLjCQg,5088
|
|
7
|
+
u2mcp/tools/app.py,sha256=k_9ROOdEbHOFWbh0sO6pL1x4YN0NjvhcnHcXfaN9Rvw,6578
|
|
8
|
+
u2mcp/tools/device.py,sha256=NBNt210Sx9Ffbw0luUL69k_SAKUOCVsI313BQ5FbtTs,9077
|
|
9
|
+
uiautomator2_mcp_server-0.1.0.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
|
|
10
|
+
uiautomator2_mcp_server-0.1.0.dist-info/entry_points.txt,sha256=uIp3lo8qa_IGxe_Wx-3sReaooXzhvViP7b5YS-RPMPY,47
|
|
11
|
+
uiautomator2_mcp_server-0.1.0.dist-info/METADATA,sha256=4WF7HSKeaKsU9LD-OQRKQl65WiUKg0yZIGpT8lnAJmY,296
|
|
12
|
+
uiautomator2_mcp_server-0.1.0.dist-info/RECORD,,
|