uiautomator2-mcp-server 0.1.2__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/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)
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", tags={"util: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)
u2mcp/tools/scrcpy.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from anyio import create_task_group, move_on_after, open_process
6
+ from anyio.abc import AnyByteReceiveStream, Process
7
+ from anyio.streams.text import TextReceiveStream
8
+ from fastmcp.server.dependencies import get_context
9
+ from fastmcp.utilities.logging import get_logger
10
+
11
+ from ..background import get_background_task_group
12
+ from ..mcp import mcp
13
+
14
+ __all__ = ("start_scrcpy", "stop_scrcpy")
15
+
16
+ # Keep track of background scrcpy processes
17
+ _background_processes: dict[int, Process] = {}
18
+
19
+
20
+ @mcp.tool("start_scrcpy", tags={"screen:mirror"})
21
+ async def start_scrcpy(serial: str = "", timeout: float = 5.0) -> int:
22
+ """Startup scrcpy in background and returns process id.
23
+
24
+ scrcpy is an application mirrors Android devices (video and audio) connected via USB or TCP/IP and allows control using the computer's keyboard and mouse.
25
+
26
+ The scrcpy process will run in the background after successful startup.
27
+ Use stop_scrcpy to terminate the process.
28
+
29
+ Args:
30
+ serial (str): Android device serialno. If empty string, connects to the unique device if only one device is connected.
31
+ timeout (float): Seconds to wait for process to confirm startup.
32
+ If process is still running after this time, startup is considered successful.
33
+
34
+ Returns:
35
+ int: process id (pid)
36
+ """
37
+
38
+ logger = get_logger(f"{__name__}.start_scrcpy")
39
+ ctx = get_context()
40
+
41
+ scrcpy_path = os.environ.get("SCRCPY", "scrcpy.exe" if os.name == "nt" else "scrcpy")
42
+ command = [scrcpy_path]
43
+ if serial := serial.strip():
44
+ command.extend(["--serial", serial])
45
+
46
+ logger.info("start scrcpy: %s", command)
47
+
48
+ process = await open_process(command)
49
+ pid = process.pid
50
+
51
+ # Stream monitoring function
52
+ async def receive(name: str, stream: AnyByteReceiveStream):
53
+ logger.info("Starting to monitor %s for pid=%s", name, pid)
54
+ async for line in TextReceiveStream(stream):
55
+ logger.debug("Received %s line: %s", name, line.strip())
56
+ match name:
57
+ case "stdout":
58
+ await ctx.info(line)
59
+ case "stderr":
60
+ await ctx.error(line)
61
+ case _:
62
+ raise ValueError(f"Unknown stream name: {name}")
63
+ logger.info("Finished monitoring %s for pid=%s", name, pid)
64
+
65
+ # Monitor streams and auto-cleanup on process exit
66
+ async def monitor_streams():
67
+ if process.stdout is None:
68
+ raise RuntimeError("stdout is None")
69
+ if process.stderr is None:
70
+ raise RuntimeError("stderr is None")
71
+ try:
72
+ async with create_task_group() as inner_tg:
73
+ for name, handle in zip(("stdout", "stderr"), (process.stdout, process.stderr)):
74
+ inner_tg.start_soon(receive, name, handle)
75
+ finally:
76
+ # Cleanup only if we still own the process (i.e., not manually stopped)
77
+ if _background_processes.pop(pid, None) is process:
78
+ logger.info("scrcpy process exited naturally, cleaning up (pid=%s)", pid)
79
+ await process.aclose()
80
+ else:
81
+ logger.info("scrcpy process was manually stopped, skipping cleanup (pid=%s)", pid)
82
+
83
+ # Start monitoring immediately (before startup wait) to capture startup logs
84
+ tg = get_background_task_group()
85
+ if tg is None:
86
+ raise RuntimeError("Monitor task group not initialized - server not started?")
87
+ tg.start_soon(monitor_streams)
88
+
89
+ # Store process
90
+ _background_processes[pid] = process
91
+
92
+ # Startup phase: wait for process exit with timeout
93
+ with move_on_after(timeout) as timeout_scope:
94
+ await process.wait()
95
+
96
+ if timeout_scope.cancel_called:
97
+ # Timeout reached, process is still running - success!
98
+ logger.info("scrcpy started successfully in background (pid=%s)", pid)
99
+ else:
100
+ # Process exited before timeout - failure
101
+ _background_processes.pop(pid, None)
102
+ if process.returncode == 0:
103
+ raise RuntimeError(f"scrcpy closed during startup (pid={pid}, return code 0)")
104
+ else:
105
+ raise RuntimeError(f"scrcpy exited during startup wait with code: {process.returncode} (pid={pid})")
106
+
107
+ return pid
108
+
109
+
110
+ @mcp.tool("stop_scrcpy", tags={"screen:mirror"})
111
+ async def stop_scrcpy(pid: int, timeout: float = 5.0) -> None:
112
+ """Stop a running scrcpy process by pid.
113
+
114
+ scrcpy is an application mirrors Android devices (video and audio) connected via USB or TCP/IP and allows control using the computer's keyboard and mouse.
115
+
116
+ Args:
117
+ pid (int): Process id of the scrcpy process to stop
118
+ timeout (float): Seconds to wait for process to terminate.
119
+
120
+ Returns:
121
+ None
122
+ """
123
+ logger = get_logger(f"{__name__}.stop_scrcpy")
124
+
125
+ if pid not in _background_processes:
126
+ raise ValueError(f"No scrcpy process found with pid: {pid}")
127
+
128
+ process = _background_processes.pop(pid)
129
+ try:
130
+ process.kill()
131
+ # Wait for process to exit with timeout
132
+ with move_on_after(timeout) as timeout_scope:
133
+ await process.wait()
134
+ finally:
135
+ # Always close the process when manually stopping
136
+ # The monitor task will detect we popped the process and skip cleanup
137
+ await process.aclose()
138
+
139
+ if timeout_scope.cancel_called:
140
+ logger.warning("scrcpy process did not exit within %ss (pid=%s)", timeout, pid)
141
+ else:
142
+ logger.info("scrcpy process stopped (pid=%s)", pid)
@@ -1,34 +1,34 @@
1
- # file generated by setuptools-scm
2
- # don't change, don't track in version control
3
-
4
- __all__ = [
5
- "__version__",
6
- "__version_tuple__",
7
- "version",
8
- "version_tuple",
9
- "__commit_id__",
10
- "commit_id",
11
- ]
12
-
13
- TYPE_CHECKING = False
14
- if TYPE_CHECKING:
15
- from typing import Tuple
16
- from typing import Union
17
-
18
- VERSION_TUPLE = Tuple[Union[int, str], ...]
19
- COMMIT_ID = Union[str, None]
20
- else:
21
- VERSION_TUPLE = object
22
- COMMIT_ID = object
23
-
24
- version: str
25
- __version__: str
26
- __version_tuple__: VERSION_TUPLE
27
- version_tuple: VERSION_TUPLE
28
- commit_id: COMMIT_ID
29
- __commit_id__: COMMIT_ID
30
-
31
- __version__ = version = '0.1.2'
32
- __version_tuple__ = version_tuple = (0, 1, 2)
33
-
34
- __commit_id__ = commit_id = None
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.2.0'
32
+ __version_tuple__ = version_tuple = (0, 2, 0)
33
+
34
+ __commit_id__ = commit_id = None