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/tools/device.py CHANGED
@@ -1,18 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
- import sys
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, 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
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("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")
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 (exit code 0).
69
+ None upon successful completion
76
70
  Raises an exception if the subprocess returns a non-zero exit code.
77
71
  """
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 = ""):
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
- del _devices[serial]
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
- - "image" (str): Base64 encoded image data in data URL format (data:image/jpeg;base64,...)
206
- - "size" (tuple[int,int]): Image dimensions as (width, height)
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 BytesIO() as fp:
216
- im.save(fp, "jpeg")
217
- im_data = fp.getvalue()
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)
u2mcp/tools/misc.py CHANGED
@@ -7,7 +7,7 @@ from ..mcp import mcp
7
7
  __all__ = ("delay",)
8
8
 
9
9
 
10
- @mcp.tool("delay")
10
+ @mcp.tool("delay", tags={"util:delay"})
11
11
  async def delay(seconds: float):
12
12
  """Delay for a specific amount of time
13
13