uiautomator2-mcp-server 0.1.2__py3-none-any.whl → 0.1.3__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/.gitignore +1 -1
- u2mcp/__init__.py +2 -2
- u2mcp/__main__.py +82 -82
- u2mcp/_version.py +34 -34
- u2mcp/mcp.py +79 -61
- u2mcp/tools/__init__.py +4 -3
- u2mcp/tools/action.py +169 -169
- u2mcp/tools/app.py +232 -231
- u2mcp/tools/device.py +259 -293
- u2mcp/tools/misc.py +17 -0
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.1.3.dist-info}/METADATA +115 -113
- uiautomator2_mcp_server-0.1.3.dist-info/RECORD +17 -0
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.1.3.dist-info}/WHEEL +1 -1
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.1.3.dist-info}/licenses/LICENSE +620 -620
- uiautomator2_mcp_server-0.1.2.dist-info/RECORD +0 -16
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.1.3.dist-info}/entry_points.txt +0 -0
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.1.3.dist-info}/top_level.txt +0 -0
u2mcp/tools/device.py
CHANGED
|
@@ -1,293 +1,259 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
from
|
|
6
|
-
from
|
|
7
|
-
from
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
from
|
|
13
|
-
from
|
|
14
|
-
from
|
|
15
|
-
from
|
|
16
|
-
|
|
17
|
-
from
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
_devices: dict[str, tuple[
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@asynccontextmanager
|
|
39
|
-
async def get_device(serial: str) -> AsyncGenerator[u2.Device]:
|
|
40
|
-
async with
|
|
41
|
-
try:
|
|
42
|
-
|
|
43
|
-
except KeyError:
|
|
44
|
-
|
|
45
|
-
def _connect():
|
|
46
|
-
_d = u2.connect(serial)
|
|
47
|
-
_d.info
|
|
48
|
-
return _d
|
|
49
|
-
|
|
50
|
-
device = await
|
|
51
|
-
|
|
52
|
-
_devices[serial] =
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
async
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
async
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
async with get_device(serial) as device:
|
|
241
|
-
|
|
242
|
-
device.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
@mcp.tool("dump_hierarchy")
|
|
261
|
-
async def dump_hierarchy(serial: str, compressed: bool = False, pretty: bool = False, max_depth: int = -1) -> str:
|
|
262
|
-
"""
|
|
263
|
-
Dump window hierarchy
|
|
264
|
-
|
|
265
|
-
Args:
|
|
266
|
-
serial (str): Android device serialno
|
|
267
|
-
compressed (bool): return compressed xml
|
|
268
|
-
pretty (bool): pretty print xml
|
|
269
|
-
max_depth (int): max depth of hierarchy
|
|
270
|
-
|
|
271
|
-
Returns:
|
|
272
|
-
str: xml string of the hierarchy tree
|
|
273
|
-
"""
|
|
274
|
-
async with get_device(serial) as device:
|
|
275
|
-
return await asyncio.to_thread(
|
|
276
|
-
device.dump_hierarchy, compressed=compressed, pretty=pretty, max_depth=max_depth if max_depth > 0 else None
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
@mcp.tool("info")
|
|
281
|
-
async def info(serial: str) -> dict[str, Any]:
|
|
282
|
-
"""
|
|
283
|
-
Get device info
|
|
284
|
-
|
|
285
|
-
Args:
|
|
286
|
-
serial (str): Android device serialno
|
|
287
|
-
|
|
288
|
-
Returns:
|
|
289
|
-
dict[str,Any]: Device info
|
|
290
|
-
"""
|
|
291
|
-
|
|
292
|
-
async with get_device(serial) as device:
|
|
293
|
-
return await asyncio.to_thread(lambda: device.info)
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import uiautomator2 as u2
|
|
11
|
+
from adbutils import adb
|
|
12
|
+
from anyio import Lock, create_task_group, open_process, to_thread
|
|
13
|
+
from anyio.abc import AnyByteReceiveStream
|
|
14
|
+
from anyio.streams.text import TextReceiveStream
|
|
15
|
+
from fastmcp.server.dependencies import get_context
|
|
16
|
+
from fastmcp.utilities.logging import get_logger
|
|
17
|
+
from PIL.Image import Image
|
|
18
|
+
|
|
19
|
+
from ..mcp import mcp
|
|
20
|
+
|
|
21
|
+
__all__ = (
|
|
22
|
+
"device_list",
|
|
23
|
+
"init",
|
|
24
|
+
"connect",
|
|
25
|
+
"disconnect",
|
|
26
|
+
"disconnect_all",
|
|
27
|
+
"window_size",
|
|
28
|
+
"screenshot",
|
|
29
|
+
"dump_hierarchy",
|
|
30
|
+
"info",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_devices: dict[str, tuple[Lock, u2.Device]] = {}
|
|
35
|
+
_global_device_connection_lock = Lock()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@asynccontextmanager
|
|
39
|
+
async def get_device(serial: str) -> AsyncGenerator[u2.Device]:
|
|
40
|
+
async with _global_device_connection_lock:
|
|
41
|
+
try:
|
|
42
|
+
lock, 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 to_thread.run_sync(_connect)
|
|
51
|
+
lock = Lock()
|
|
52
|
+
_devices[serial] = lock, device
|
|
53
|
+
|
|
54
|
+
async with lock:
|
|
55
|
+
yield device
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mcp.tool("device_list")
|
|
59
|
+
async def device_list() -> list[dict[str, Any]]:
|
|
60
|
+
device_list = await to_thread.run_sync(adb.device_list)
|
|
61
|
+
return [d.info for d in device_list]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@mcp.tool("init")
|
|
65
|
+
async def init(serial: str = ""):
|
|
66
|
+
"""Install essential resources to device.
|
|
67
|
+
|
|
68
|
+
Important:
|
|
69
|
+
This tool must be run on the Android device before running operation actions.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
serial (str): Android device serialno to initialize. If empty string, all devices will be initialized.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
None upon successful completion (exit code 0).
|
|
76
|
+
Raises an exception if the subprocess returns a non-zero exit code.
|
|
77
|
+
"""
|
|
78
|
+
logger = get_logger(f"{__name__}.init")
|
|
79
|
+
command = [sys.executable, "-m", "uiautomator2", "init"]
|
|
80
|
+
if serial := serial.strip():
|
|
81
|
+
command.extend(["--serial", serial])
|
|
82
|
+
|
|
83
|
+
logger.info("Running uiautomator2 init command: %s %s", sys.executable, command)
|
|
84
|
+
# Capture stdio prevent polluting the output
|
|
85
|
+
ctx = get_context()
|
|
86
|
+
|
|
87
|
+
async def receive(name: str, stream: AnyByteReceiveStream):
|
|
88
|
+
async for line in TextReceiveStream(stream):
|
|
89
|
+
match name:
|
|
90
|
+
case "stdout":
|
|
91
|
+
await ctx.info(line)
|
|
92
|
+
case "stderr":
|
|
93
|
+
await ctx.error(line)
|
|
94
|
+
case _:
|
|
95
|
+
raise ValueError(f"Unknown stream name: {name}")
|
|
96
|
+
|
|
97
|
+
async with await open_process(command) as process:
|
|
98
|
+
if process.stdout is None:
|
|
99
|
+
raise RuntimeError("stdout is None")
|
|
100
|
+
if process.stderr is None:
|
|
101
|
+
raise RuntimeError("stderr is None")
|
|
102
|
+
async with create_task_group() as tg:
|
|
103
|
+
for name, handle in zip(("stdout", "stderr"), (process.stdout, process.stderr)):
|
|
104
|
+
tg.start_soon(receive, name, handle)
|
|
105
|
+
|
|
106
|
+
if exit_code := process.returncode:
|
|
107
|
+
raise RuntimeError(f"uiautomator2 init command exited with non-zero code: {exit_code}")
|
|
108
|
+
else:
|
|
109
|
+
logger.info("uiautomator2 init command exited with code: %s", exit_code)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@mcp.tool("connect")
|
|
113
|
+
async def connect(serial: str = ""):
|
|
114
|
+
"""Connect to an Android device
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
serial (str): Android device serial number. If empty string, connects to the unique device if only one device is connected.
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
dict[str,Any]: Device information
|
|
122
|
+
"""
|
|
123
|
+
global _devices
|
|
124
|
+
device: u2.Device | None = None
|
|
125
|
+
|
|
126
|
+
logger = get_logger(f"{__name__}.connect")
|
|
127
|
+
|
|
128
|
+
if serial := serial.strip():
|
|
129
|
+
try:
|
|
130
|
+
async with get_device(serial) as device_1:
|
|
131
|
+
# Found, then check if it's still connected
|
|
132
|
+
try:
|
|
133
|
+
return await to_thread.run_sync(lambda: device_1.device_info | device_1.info)
|
|
134
|
+
except u2.ConnectError as e:
|
|
135
|
+
# Found, but not connected, delete it
|
|
136
|
+
logger.warning("Device %s is no longer connected, delete it!", serial)
|
|
137
|
+
del _devices[serial]
|
|
138
|
+
raise e from None
|
|
139
|
+
except KeyError:
|
|
140
|
+
# Not found, need a new connection!
|
|
141
|
+
logger.info("Cannot find device with serial %s, connecting...")
|
|
142
|
+
|
|
143
|
+
# make new connection here!
|
|
144
|
+
async with _global_device_connection_lock:
|
|
145
|
+
device = await to_thread.run_sync(u2.connect, serial)
|
|
146
|
+
if device is None:
|
|
147
|
+
raise RuntimeError("Cannot connect to device")
|
|
148
|
+
logger.info("Connected to device %s", device.serial)
|
|
149
|
+
result = await to_thread.run_sync(lambda: device.device_info | device.info)
|
|
150
|
+
_devices[device.serial] = Lock(), device
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@mcp.tool("disconnect")
|
|
155
|
+
async def disconnect(serial: str):
|
|
156
|
+
"""Disconnect from an Android device
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
serial (str): Android device serialno
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
None
|
|
163
|
+
"""
|
|
164
|
+
if not (serial := serial.strip()):
|
|
165
|
+
raise ValueError("serial cannot be empty")
|
|
166
|
+
async with _global_device_connection_lock:
|
|
167
|
+
del _devices[serial]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@mcp.tool("disconnect_all")
|
|
171
|
+
async def disconnect_all():
|
|
172
|
+
"""Disconnect from all Android devices"""
|
|
173
|
+
async with _global_device_connection_lock:
|
|
174
|
+
_devices.clear()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@mcp.tool("window_size")
|
|
178
|
+
async def window_size(serial: str) -> dict[str, int]:
|
|
179
|
+
"""Get window size of an Android device
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
serial (str): Android device serialno
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
dict[str,int]: Window size object:
|
|
186
|
+
- "width" (int): Window width
|
|
187
|
+
- "height" (int): Window height
|
|
188
|
+
"""
|
|
189
|
+
async with get_device(serial) as device:
|
|
190
|
+
width, height = await to_thread.run_sync(device.window_size)
|
|
191
|
+
return {"width": width, "height": height}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@mcp.tool("screenshot")
|
|
195
|
+
async def screenshot(serial: str, display_id: int = -1) -> dict[str, Any]:
|
|
196
|
+
"""
|
|
197
|
+
Take screenshot of device
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
serial (str): Android device serialno
|
|
201
|
+
display_id (int): use specific display if device has multiple screen. Defaults to -1.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
dict[str,Any]: Screenshot image JPEG data with the following keys:
|
|
205
|
+
- "image" (str): Base64 encoded image data in data URL format (data:image/jpeg;base64,...)
|
|
206
|
+
- "size" (tuple[int,int]): Image dimensions as (width, height)
|
|
207
|
+
"""
|
|
208
|
+
display_id = int(display_id)
|
|
209
|
+
async with get_device(serial) as device:
|
|
210
|
+
im = await to_thread.run_sync(lambda: device.screenshot(display_id=display_id if display_id >= 0 else None))
|
|
211
|
+
|
|
212
|
+
if not isinstance(im, Image):
|
|
213
|
+
raise RuntimeError("Invalid image")
|
|
214
|
+
|
|
215
|
+
with BytesIO() as fp:
|
|
216
|
+
im.save(fp, "jpeg")
|
|
217
|
+
im_data = fp.getvalue()
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
"width": im.width,
|
|
221
|
+
"height": im.height,
|
|
222
|
+
"image": "data:image/jpeg;base64," + b64encode(im_data).decode(),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@mcp.tool("dump_hierarchy")
|
|
227
|
+
async def dump_hierarchy(serial: str, compressed: bool = False, pretty: bool = False, max_depth: int = -1) -> str:
|
|
228
|
+
"""
|
|
229
|
+
Dump window hierarchy
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
serial (str): Android device serialno
|
|
233
|
+
compressed (bool): return compressed xml
|
|
234
|
+
pretty (bool): pretty print xml
|
|
235
|
+
max_depth (int): max depth of hierarchy
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
str: xml string of the hierarchy tree
|
|
239
|
+
"""
|
|
240
|
+
async with get_device(serial) as device:
|
|
241
|
+
return await to_thread.run_sync(
|
|
242
|
+
lambda: device.dump_hierarchy(compressed=compressed, pretty=pretty, max_depth=max_depth if max_depth > 0 else None)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@mcp.tool("info")
|
|
247
|
+
async def info(serial: str) -> dict[str, Any]:
|
|
248
|
+
"""
|
|
249
|
+
Get device info
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
serial (str): Android device serialno
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
dict[str,Any]: Device info
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
async with get_device(serial) as device:
|
|
259
|
+
return await to_thread.run_sync(lambda: device.info)
|
u2mcp/tools/misc.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import anyio
|
|
4
|
+
|
|
5
|
+
from ..mcp import mcp
|
|
6
|
+
|
|
7
|
+
__all__ = ("delay",)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp.tool("delay")
|
|
11
|
+
async def delay(seconds: float):
|
|
12
|
+
"""Delay for a specific amount of time
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
seconds(float): Delay duration in seconds
|
|
16
|
+
"""
|
|
17
|
+
await anyio.sleep(seconds)
|