uiautomator2-mcp-server 0.1.3__py3-none-any.whl → 0.2.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/.gitignore +1 -1
- u2mcp/__init__.py +1 -2
- u2mcp/__main__.py +168 -57
- u2mcp/background.py +19 -0
- u2mcp/health.py +67 -0
- u2mcp/helpers.py +222 -0
- u2mcp/mcp.py +111 -18
- u2mcp/middlewares.py +40 -0
- u2mcp/tools/__init__.py +4 -0
- u2mcp/tools/action.py +9 -35
- u2mcp/tools/app.py +13 -13
- u2mcp/tools/clipboard.py +35 -0
- u2mcp/tools/device.py +119 -71
- u2mcp/tools/element.py +267 -0
- u2mcp/tools/input.py +47 -0
- u2mcp/tools/misc.py +1 -1
- u2mcp/tools/scrcpy.py +142 -0
- u2mcp/{_version.py → version.py} +2 -2
- uiautomator2_mcp_server-0.2.0.dist-info/METADATA +738 -0
- uiautomator2_mcp_server-0.2.0.dist-info/RECORD +25 -0
- {uiautomator2_mcp_server-0.1.3.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/WHEEL +1 -1
- {uiautomator2_mcp_server-0.1.3.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/entry_points.txt +1 -0
- uiautomator2_mcp_server-0.2.0.dist-info/licenses/LICENSE +190 -0
- uiautomator2_mcp_server-0.1.3.dist-info/METADATA +0 -115
- uiautomator2_mcp_server-0.1.3.dist-info/RECORD +0 -17
- uiautomator2_mcp_server-0.1.3.dist-info/licenses/LICENSE +0 -620
- {uiautomator2_mcp_server-0.1.3.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/top_level.txt +0 -0
u2mcp/tools/device.py
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import argparse
|
|
4
4
|
from base64 import b64encode
|
|
5
5
|
from collections.abc import AsyncGenerator
|
|
6
|
-
from contextlib import asynccontextmanager
|
|
6
|
+
from contextlib import asynccontextmanager, closing
|
|
7
7
|
from io import BytesIO
|
|
8
|
+
from pathlib import Path
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
10
11
|
import uiautomator2 as u2
|
|
11
12
|
from adbutils import adb
|
|
12
|
-
from anyio import Lock,
|
|
13
|
-
from anyio.abc import AnyByteReceiveStream
|
|
14
|
-
from anyio.streams.text import TextReceiveStream
|
|
15
|
-
from fastmcp.server.dependencies import get_context
|
|
13
|
+
from anyio import Lock, to_thread
|
|
16
14
|
from fastmcp.utilities.logging import get_logger
|
|
17
15
|
from PIL.Image import Image
|
|
18
16
|
|
|
@@ -20,12 +18,14 @@ from ..mcp import mcp
|
|
|
20
18
|
|
|
21
19
|
__all__ = (
|
|
22
20
|
"device_list",
|
|
21
|
+
"shell_command",
|
|
23
22
|
"init",
|
|
24
23
|
"connect",
|
|
25
24
|
"disconnect",
|
|
26
25
|
"disconnect_all",
|
|
27
26
|
"window_size",
|
|
28
27
|
"screenshot",
|
|
28
|
+
"save_screenshot",
|
|
29
29
|
"dump_hierarchy",
|
|
30
30
|
"info",
|
|
31
31
|
)
|
|
@@ -55,15 +55,9 @@ async def get_device(serial: str) -> AsyncGenerator[u2.Device]:
|
|
|
55
55
|
yield device
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
@mcp.tool("
|
|
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")
|
|
58
|
+
@mcp.tool("init", tags={"device:manage"})
|
|
65
59
|
async def init(serial: str = ""):
|
|
66
|
-
"""Install essential resources to device.
|
|
60
|
+
"""Install essential resources (minicap, minitouch, uiautomator ...) to device.
|
|
67
61
|
|
|
68
62
|
Important:
|
|
69
63
|
This tool must be run on the Android device before running operation actions.
|
|
@@ -72,51 +66,70 @@ async def init(serial: str = ""):
|
|
|
72
66
|
serial (str): Android device serialno to initialize. If empty string, all devices will be initialized.
|
|
73
67
|
|
|
74
68
|
Returns:
|
|
75
|
-
None upon successful completion
|
|
69
|
+
None upon successful completion
|
|
76
70
|
Raises an exception if the subprocess returns a non-zero exit code.
|
|
77
71
|
"""
|
|
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
|
-
|
|
72
|
+
from uiautomator2.__main__ import cmd_init
|
|
73
|
+
|
|
74
|
+
args = argparse.Namespace(serial=serial, serial_optional=None)
|
|
75
|
+
return await to_thread.run_sync(cmd_init, args)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@mcp.tool("purge", tags={"device:manage"})
|
|
79
|
+
async def purge(serial: str = ""):
|
|
80
|
+
"""Purge all resources (minicap, minitouch, uiautomator ...) from device.
|
|
81
|
+
|
|
82
|
+
Important:
|
|
83
|
+
This tool must be run on the Android device before running operation actions.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
serial (str): Android device serialno to purge. If empty string, all devices will be purged.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
None upon successful completion
|
|
90
|
+
Raises an exception if the subprocess returns a non-zero exit code.
|
|
91
|
+
"""
|
|
92
|
+
from uiautomator2.__main__ import cmd_purge
|
|
93
|
+
|
|
94
|
+
args = argparse.Namespace(serial=serial)
|
|
95
|
+
return await to_thread.run_sync(cmd_purge, args)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@mcp.tool("shell_command", tags={"device:shell"})
|
|
99
|
+
async def shell_command(serial: str, command: str, timeout: float = 60) -> tuple[int, str]:
|
|
100
|
+
"""Run a shell command on an Android device
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
serial (str): Android device serialno
|
|
104
|
+
command (str): Shell command to run
|
|
105
|
+
timeout (float): Seconds to wait for command to complete.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
tuple[int,str]: Return code and output of the command
|
|
109
|
+
"""
|
|
110
|
+
async with get_device(serial) as device:
|
|
111
|
+
return_value = await to_thread.run_sync(device.adb_device.shell2, command, timeout)
|
|
112
|
+
return return_value.returncode, return_value.output
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@mcp.tool("device_list", tags={"device:info"})
|
|
116
|
+
async def device_list() -> list[dict[str, Any]]:
|
|
117
|
+
"""List of Adb Device with state:device
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
list[dict[str,Any]]: List Adb Device information
|
|
121
|
+
"""
|
|
122
|
+
device_list = await to_thread.run_sync(adb.device_list)
|
|
123
|
+
return [d.info for d in device_list]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@mcp.tool("connect", tags={"device:manage"})
|
|
127
|
+
async def connect(serial: str = "") -> dict[str, Any]:
|
|
114
128
|
"""Connect to an Android device
|
|
115
129
|
|
|
116
130
|
Args:
|
|
117
131
|
serial (str): Android device serial number. If empty string, connects to the unique device if only one device is connected.
|
|
118
132
|
|
|
119
|
-
|
|
120
133
|
Returns:
|
|
121
134
|
dict[str,Any]: Device information
|
|
122
135
|
"""
|
|
@@ -151,7 +164,7 @@ async def connect(serial: str = ""):
|
|
|
151
164
|
return result
|
|
152
165
|
|
|
153
166
|
|
|
154
|
-
@mcp.tool("disconnect")
|
|
167
|
+
@mcp.tool("disconnect", tags={"device:manage"})
|
|
155
168
|
async def disconnect(serial: str):
|
|
156
169
|
"""Disconnect from an Android device
|
|
157
170
|
|
|
@@ -164,17 +177,17 @@ async def disconnect(serial: str):
|
|
|
164
177
|
if not (serial := serial.strip()):
|
|
165
178
|
raise ValueError("serial cannot be empty")
|
|
166
179
|
async with _global_device_connection_lock:
|
|
167
|
-
|
|
180
|
+
_devices.pop(serial, None)
|
|
168
181
|
|
|
169
182
|
|
|
170
|
-
@mcp.tool("disconnect_all")
|
|
183
|
+
@mcp.tool("disconnect_all", tags={"device:manage"})
|
|
171
184
|
async def disconnect_all():
|
|
172
185
|
"""Disconnect from all Android devices"""
|
|
173
186
|
async with _global_device_connection_lock:
|
|
174
187
|
_devices.clear()
|
|
175
188
|
|
|
176
189
|
|
|
177
|
-
@mcp.tool("window_size")
|
|
190
|
+
@mcp.tool("window_size", tags={"device:info"})
|
|
178
191
|
async def window_size(serial: str) -> dict[str, int]:
|
|
179
192
|
"""Get window size of an Android device
|
|
180
193
|
|
|
@@ -191,39 +204,74 @@ async def window_size(serial: str) -> dict[str, int]:
|
|
|
191
204
|
return {"width": width, "height": height}
|
|
192
205
|
|
|
193
206
|
|
|
194
|
-
@mcp.tool("screenshot")
|
|
195
|
-
async def screenshot(serial: str, display_id: int = -1) -> dict[str, Any]:
|
|
207
|
+
@mcp.tool("screenshot", tags={"device:capture", "screen:capture"})
|
|
208
|
+
async def screenshot(serial: str, format: str = "jpeg", display_id: int = -1) -> dict[str, Any]:
|
|
196
209
|
"""
|
|
197
210
|
Take screenshot of device
|
|
198
211
|
|
|
199
212
|
Args:
|
|
200
|
-
serial (str): Android device serialno
|
|
213
|
+
serial (str): Android device serialno.
|
|
214
|
+
format (str): Image format. Defaults to "jpeg".
|
|
201
215
|
display_id (int): use specific display if device has multiple screen. Defaults to -1.
|
|
202
216
|
|
|
203
217
|
Returns:
|
|
204
218
|
dict[str,Any]: Screenshot image JPEG data with the following keys:
|
|
205
|
-
-
|
|
206
|
-
-
|
|
219
|
+
- image (str): Base64 encoded image data in data URL format (data:image/jpeg;base64,...)
|
|
220
|
+
- height (int): Image height
|
|
221
|
+
- width (int): Image width
|
|
222
|
+
"""
|
|
223
|
+
display_id = int(display_id)
|
|
224
|
+
async with get_device(serial) as device:
|
|
225
|
+
im = await to_thread.run_sync(lambda: device.screenshot(display_id=display_id if display_id >= 0 else None))
|
|
226
|
+
|
|
227
|
+
if not isinstance(im, Image):
|
|
228
|
+
raise RuntimeError("Invalid image")
|
|
229
|
+
|
|
230
|
+
with closing(im):
|
|
231
|
+
with BytesIO() as fp:
|
|
232
|
+
im.save(fp, format)
|
|
233
|
+
im_data = fp.getvalue()
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"image": "data:image/jpeg;base64," + b64encode(im_data).decode(),
|
|
237
|
+
"height": im.height,
|
|
238
|
+
"width": im.width,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@mcp.tool("save_screenshot", tags={"device:capture", "screen:capture"})
|
|
243
|
+
async def save_screenshot(serial: str, file: str, display_id: int = -1) -> str:
|
|
244
|
+
"""
|
|
245
|
+
Save screenshot of device to file
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
serial(str): Android device serial number.
|
|
249
|
+
file(str): File path to save the screenshot. Supports both absolute and relative paths.
|
|
250
|
+
display_id(int): Use specific display if device has multiple screens. Defaults to -1 (default display).
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
str: Screenshot save file path
|
|
207
254
|
"""
|
|
208
255
|
display_id = int(display_id)
|
|
256
|
+
|
|
209
257
|
async with get_device(serial) as device:
|
|
210
258
|
im = await to_thread.run_sync(lambda: device.screenshot(display_id=display_id if display_id >= 0 else None))
|
|
211
259
|
|
|
212
260
|
if not isinstance(im, Image):
|
|
213
261
|
raise RuntimeError("Invalid image")
|
|
214
262
|
|
|
215
|
-
with
|
|
216
|
-
|
|
217
|
-
|
|
263
|
+
with closing(im):
|
|
264
|
+
# Convert path to Path object and resolve
|
|
265
|
+
file_path = Path(file)
|
|
266
|
+
# Create parent directory if it doesn't exist
|
|
267
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
# Save the image
|
|
269
|
+
im.save(file_path)
|
|
218
270
|
|
|
219
|
-
return
|
|
220
|
-
"width": im.width,
|
|
221
|
-
"height": im.height,
|
|
222
|
-
"image": "data:image/jpeg;base64," + b64encode(im_data).decode(),
|
|
223
|
-
}
|
|
271
|
+
return file_path.resolve().as_posix()
|
|
224
272
|
|
|
225
273
|
|
|
226
|
-
@mcp.tool("dump_hierarchy")
|
|
274
|
+
@mcp.tool("dump_hierarchy", tags={"device:capture"})
|
|
227
275
|
async def dump_hierarchy(serial: str, compressed: bool = False, pretty: bool = False, max_depth: int = -1) -> str:
|
|
228
276
|
"""
|
|
229
277
|
Dump window hierarchy
|
|
@@ -243,7 +291,7 @@ async def dump_hierarchy(serial: str, compressed: bool = False, pretty: bool = F
|
|
|
243
291
|
)
|
|
244
292
|
|
|
245
293
|
|
|
246
|
-
@mcp.tool("info")
|
|
294
|
+
@mcp.tool("info", tags={"device:info"})
|
|
247
295
|
async def info(serial: str) -> dict[str, Any]:
|
|
248
296
|
"""
|
|
249
297
|
Get device info
|
u2mcp/tools/element.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from base64 import b64encode
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from anyio import to_thread
|
|
8
|
+
from PIL.Image import Image
|
|
9
|
+
|
|
10
|
+
from ..mcp import mcp
|
|
11
|
+
from .device import get_device
|
|
12
|
+
|
|
13
|
+
__all__ = (
|
|
14
|
+
"activity_wait",
|
|
15
|
+
"element_wait",
|
|
16
|
+
"element_wait_gone",
|
|
17
|
+
"element_click",
|
|
18
|
+
"element_click_nowait",
|
|
19
|
+
"element_click_until_gone",
|
|
20
|
+
"element_long_click",
|
|
21
|
+
"element_screenshot",
|
|
22
|
+
"element_get_text",
|
|
23
|
+
"element_set_text",
|
|
24
|
+
"element_bounds",
|
|
25
|
+
"element_swipe",
|
|
26
|
+
"element_scroll",
|
|
27
|
+
"element_scroll_to",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@mcp.tool("activity_wait", tags={"element:wait"})
|
|
32
|
+
async def activity_wait(serial: str, activity: str, timeout: float = 20.0) -> bool:
|
|
33
|
+
"""wait activity
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
serial (str): Android device serialno
|
|
37
|
+
activity (str): name of activity
|
|
38
|
+
timeout (float): max wait time
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
bool of activity
|
|
42
|
+
"""
|
|
43
|
+
async with get_device(serial) as device:
|
|
44
|
+
# timeout: float here is actually no problem
|
|
45
|
+
return await to_thread.run_sync(device.wait_activity, activity, timeout) # type: ignore[arg-type]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@mcp.tool("element_wait", tags={"element:wait"})
|
|
49
|
+
async def element_wait(serial: str, xpath: str, timeout: float | None = None) -> bool:
|
|
50
|
+
"""
|
|
51
|
+
wait until element found
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
serial (str): Android device serialno
|
|
55
|
+
xpath (str): element xpath
|
|
56
|
+
timeout (Optional float): seconds wait element show up
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
bool: if element found
|
|
60
|
+
"""
|
|
61
|
+
async with get_device(serial) as device:
|
|
62
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).wait(timeout))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@mcp.tool("element_wait_gone", tags={"element:wait"})
|
|
66
|
+
async def element_wait_gone(serial: str, xpath: str, timeout: float | None = None) -> bool:
|
|
67
|
+
"""
|
|
68
|
+
wait until element gone
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
serial (str): Android device serialno
|
|
72
|
+
xpath (str): element xpath
|
|
73
|
+
timeout (Optional float): seconds wait element show up
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
bool: True if gone else False
|
|
77
|
+
"""
|
|
78
|
+
async with get_device(serial) as device:
|
|
79
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).wait_gone(timeout))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@mcp.tool("element_click", tags={"element:interact"})
|
|
83
|
+
async def element_click(serial: str, xpath: str, timeout: float | None = None) -> bool:
|
|
84
|
+
"""
|
|
85
|
+
find element and perform click
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
serial (str): Android device serialno
|
|
89
|
+
xpath (str): element xpath
|
|
90
|
+
timeout (Optional float): seconds wait element show up
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
bool: True if click success else False
|
|
94
|
+
"""
|
|
95
|
+
async with get_device(serial) as device:
|
|
96
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).click_exists(timeout))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@mcp.tool("element_click_nowait", tags={"element:interact"})
|
|
100
|
+
async def element_click_nowait(serial: str, xpath: str):
|
|
101
|
+
"""
|
|
102
|
+
find element and perform click
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
serial (str): Android device serialno
|
|
106
|
+
xpath (str): element xpath
|
|
107
|
+
"""
|
|
108
|
+
async with get_device(serial) as device:
|
|
109
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).click_nowait())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@mcp.tool("element_click_until_gone", tags={"element:interact"})
|
|
113
|
+
async def element_click_until_gone(serial: str, xpath: str, maxretry=10, interval=1.0) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
find element and click until element is gone
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
serial (str): Android device serialno
|
|
119
|
+
xpath (str): element xpath
|
|
120
|
+
maxretry (int): max click times
|
|
121
|
+
interval (float): sleep time between clicks
|
|
122
|
+
|
|
123
|
+
Return:
|
|
124
|
+
bool: if element is gone
|
|
125
|
+
"""
|
|
126
|
+
async with get_device(serial) as device:
|
|
127
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).click_gone(maxretry, interval))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@mcp.tool("element_long_click", tags={"element:interact"})
|
|
131
|
+
async def element_long_click(serial: str, xpath: str):
|
|
132
|
+
"""
|
|
133
|
+
find element and perform long click
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
serial (str): Android device serialno
|
|
137
|
+
xpath (str): element xpath
|
|
138
|
+
"""
|
|
139
|
+
async with get_device(serial) as device:
|
|
140
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).long_click())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@mcp.tool("element_screenshot", tags={"element:capture"})
|
|
144
|
+
async def element_screenshot(serial: str, xpath: str) -> dict[str, Any]:
|
|
145
|
+
"""
|
|
146
|
+
find element and take screenshot
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
serial (str): Android device serialno
|
|
150
|
+
xpath (str): element xpath
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
dict[str,Any]: Screenshot image JPEG data with the following keys:
|
|
154
|
+
- image (str): Base64 encoded image data in data URL format (data:image/jpeg;base64,...)
|
|
155
|
+
- size (tuple[int,int]): Image dimensions as (width, height)
|
|
156
|
+
"""
|
|
157
|
+
async with get_device(serial) as device:
|
|
158
|
+
im = await to_thread.run_sync(lambda: device.xpath(xpath).screenshot())
|
|
159
|
+
if not isinstance(im, Image):
|
|
160
|
+
raise RuntimeError("Invalid image")
|
|
161
|
+
|
|
162
|
+
with BytesIO() as fp:
|
|
163
|
+
im.save(fp, "jpeg")
|
|
164
|
+
im_data = fp.getvalue()
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
"width": im.width,
|
|
168
|
+
"height": im.height,
|
|
169
|
+
"image": "data:image/jpeg;base64," + b64encode(im_data).decode(),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@mcp.tool("element_get_text", tags={"element:query"})
|
|
174
|
+
async def element_get_text(serial: str, xpath: str) -> str | None:
|
|
175
|
+
"""
|
|
176
|
+
find and get element text
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
serial (str): Android device serialno
|
|
180
|
+
xpath (str): element xpath
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
str: string of node text
|
|
184
|
+
None: if element has no text attribute
|
|
185
|
+
"""
|
|
186
|
+
async with get_device(serial) as device:
|
|
187
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).get_text())
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@mcp.tool("element_set_text", tags={"element:modify"})
|
|
191
|
+
async def element_set_text(serial: str, xpath: str, text: str) -> None:
|
|
192
|
+
"""
|
|
193
|
+
find and set element text
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
serial (str): Android device serialno
|
|
197
|
+
xpath (str): element xpath
|
|
198
|
+
text (str): string of node text
|
|
199
|
+
"""
|
|
200
|
+
async with get_device(serial) as device:
|
|
201
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).set_text(text))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@mcp.tool("element_bounds", tags={"element:query"})
|
|
205
|
+
async def element_bounds(serial: str, xpath: str) -> tuple[int, int, int, int]:
|
|
206
|
+
"""
|
|
207
|
+
find an element and get bounds
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
serial (str): Android device serialno
|
|
211
|
+
xpath (str): element xpath
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
tuple[int]: tuple of (left, top, right, bottom)
|
|
215
|
+
"""
|
|
216
|
+
async with get_device(serial) as device:
|
|
217
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).bounds)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@mcp.tool("element_swipe", tags={"element:gesture"})
|
|
221
|
+
async def element_swipe(serial: str, xpath: str, direction: str, scale: float = 0.6):
|
|
222
|
+
"""
|
|
223
|
+
find an element and swipe
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
serial (str): Android device serialno
|
|
227
|
+
xpath (str): element xpath
|
|
228
|
+
direction: one of ["left", "right", "up", "down"]
|
|
229
|
+
scale: percent of swipe, range (0, 1.0)
|
|
230
|
+
"""
|
|
231
|
+
async with get_device(serial) as device:
|
|
232
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).swipe(direction, scale))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@mcp.tool("element_scroll", tags={"element:gesture"})
|
|
236
|
+
async def element_scroll(serial: str, xpath: str, direction: str = "forward") -> bool:
|
|
237
|
+
"""
|
|
238
|
+
find an element and scroll
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
serial (str): Android device serialno
|
|
242
|
+
xpath (str): element xpath
|
|
243
|
+
direction (str): scroll direction, one of ["forward", "backward"]
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
bool: if can be scroll again
|
|
247
|
+
"""
|
|
248
|
+
async with get_device(serial) as device:
|
|
249
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).swipe(direction))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@mcp.tool("element_scroll_to", tags={"element:gesture"})
|
|
253
|
+
async def element_scroll_to(serial: str, xpath: str, direction: str = "forward", max_swipes: int = 10):
|
|
254
|
+
"""
|
|
255
|
+
find an element and scroll to
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
serial (str): Android device serialno
|
|
259
|
+
xpath (str): element xpath
|
|
260
|
+
direction (str): scroll direction, one of ["forward", "backward"]
|
|
261
|
+
max_swipes (int): max swipe times
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
bool: if can be scroll again
|
|
265
|
+
"""
|
|
266
|
+
async with get_device(serial) as device:
|
|
267
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).scroll_to(direction, max_swipes))
|
u2mcp/tools/input.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from anyio import to_thread
|
|
4
|
+
|
|
5
|
+
from ..mcp import mcp
|
|
6
|
+
from .device import get_device
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"send_text",
|
|
10
|
+
"clear_text",
|
|
11
|
+
"hide_keyboard",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@mcp.tool("send_text", tags={"input:text"})
|
|
16
|
+
async def send_text(serial: str, text: str, clear: bool = False):
|
|
17
|
+
"""Send text to the current input field
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
serial (str): Android device serialno
|
|
21
|
+
text (str): input text
|
|
22
|
+
clear: clear text before input
|
|
23
|
+
"""
|
|
24
|
+
async with get_device(serial) as device:
|
|
25
|
+
await to_thread.run_sync(device.send_keys, text, clear)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@mcp.tool("clear_text", tags={"input:text"})
|
|
29
|
+
async def clear_text(serial: str):
|
|
30
|
+
"""Clear text in the current input field
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
serial (str): Android device serialno
|
|
34
|
+
"""
|
|
35
|
+
async with get_device(serial) as device:
|
|
36
|
+
await to_thread.run_sync(device.clear_text)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@mcp.tool("hide_keyboard", tags={"input:keyboard"})
|
|
40
|
+
async def hide_keyboard(serial: str):
|
|
41
|
+
"""Hide keyboard
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
serial (str): Android device serialno
|
|
45
|
+
"""
|
|
46
|
+
async with get_device(serial) as device:
|
|
47
|
+
await to_thread.run_sync(device.hide_keyboard)
|