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/device.py CHANGED
@@ -1,293 +1,307 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import sys
5
- from base64 import b64encode
6
- from collections.abc import AsyncGenerator
7
- from contextlib import asynccontextmanager
8
- from io import BytesIO
9
- from typing import Any, Literal
10
-
11
- import uiautomator2 as u2
12
- from adbutils import adb
13
- from fastmcp.server.dependencies import get_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) -> AsyncGenerator[u2.Device]:
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 = ""):
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
- ctx = get_context()
114
-
115
- while True:
116
- tag, line = await output_queue.get()
117
-
118
- if not line: # This was the EOF sentinel (empty string)
119
- completed_streams += 1
120
- if completed_streams == len(tasks):
121
- output_queue.task_done()
122
- break # Both streams are done, exit the main consumer loop
123
-
124
- # Process the actual line data
125
- if line := line.strip():
126
- logger.info("%s: %s", tag, line)
127
- if tag == "stdout":
128
- await ctx.info(line)
129
- else:
130
- await ctx.warning(line)
131
-
132
- output_queue.task_done()
133
-
134
- # Wait for the tasks to formally complete and the process to exit
135
- logger.info("waiting for uiautomator2 init command to complete")
136
- await asyncio.gather(*tasks)
137
- exit_code = await process.wait()
138
- logger.info("uiautomator2 init command exited with code: %s", exit_code)
139
- if exit_code != 0:
140
- raise RuntimeError(f"uiautomator2 init command exited with non-zero code: {exit_code}")
141
-
142
-
143
- @mcp.tool("connect")
144
- async def connect(serial: str = ""):
145
- """Connect to an Android device
146
-
147
- Args:
148
- serial (str): Android device serial number. If empty string, connects to the unique device if only one device is connected.
149
-
150
-
151
- Returns:
152
- dict[str,Any]: Device information
153
- """
154
- global _devices
155
- device: u2.Device | None = None
156
-
157
- logger = get_logger(f"{__name__}.connect")
158
-
159
- if serial := serial.strip():
160
- try:
161
- async with get_device(serial) as device_1:
162
- # Found, then check if it's still connected
163
- try:
164
- return await asyncio.to_thread(lambda: device_1.device_info | device_1.info)
165
- except u2.ConnectError as e:
166
- # Found, but not connected, delete it
167
- logger.warning("Device %s is no longer connected, delete it!", serial)
168
- del _devices[serial]
169
- raise e from None
170
- except KeyError:
171
- # Not found, need a new connection!
172
- logger.info("Cannot find device with serial %s, connecting...")
173
-
174
- # make new connection here!
175
- async with _device_connect_lock:
176
- device = await asyncio.to_thread(u2.connect, serial)
177
- if device is None:
178
- raise RuntimeError("Cannot connect to device")
179
- logger.info("Connected to device %s", device.serial)
180
- result = await asyncio.to_thread(lambda: device.device_info | device.info)
181
- _devices[device.serial] = asyncio.Semaphore(), device
182
- return result
183
-
184
-
185
- @mcp.tool("disconnect")
186
- async def disconnect(serial: str):
187
- """Disconnect from an Android device
188
-
189
- Args:
190
- serial (str): Android device serialno
191
-
192
- Returns:
193
- None
194
- """
195
- if not (serial := serial.strip()):
196
- raise ValueError("serial cannot be empty")
197
- async with _device_connect_lock:
198
- del _devices[serial]
199
-
200
-
201
- @mcp.tool("disconnect_all")
202
- async def disconnect_all():
203
- """Disconnect from all Android devices"""
204
- async with _device_connect_lock:
205
- _devices.clear()
206
-
207
-
208
- @mcp.tool("window_size")
209
- async def window_size(serial: str) -> dict[str, int]:
210
- """Get window size of an Android device
211
-
212
- Args:
213
- serial (str): Android device serialno
214
-
215
- Returns:
216
- dict[str,int]: Window size object:
217
- - "width" (int): Window width
218
- - "height" (int): Window height
219
- """
220
- async with get_device(serial) as device:
221
- width, height = await asyncio.to_thread(device.window_size)
222
- return {"width": width, "height": height}
223
-
224
-
225
- @mcp.tool("screenshot")
226
- async def screenshot(serial: str, display_id: int = -1) -> dict[str, Any]:
227
- """
228
- Take screenshot of device
229
-
230
- Args:
231
- serial (str): Android device serialno
232
- display_id (int): use specific display if device has multiple screen. Defaults to -1.
233
-
234
- Returns:
235
- dict[str,Any]: Screenshot image JPEG data with the following keys:
236
- - "image" (str): Base64 encoded image data in data URL format (data:image/jpeg;base64,...)
237
- - "size" (tuple[int,int]): Image dimensions as (width, height)
238
- """
239
- display_id = int(display_id)
240
- async with get_device(serial) as device:
241
- im = await asyncio.to_thread(
242
- device.screenshot,
243
- display_id=display_id if display_id >= 0 else None,
244
- ) # type: ignore[arg-type]
245
-
246
- if not isinstance(im, Image):
247
- raise RuntimeError("Invalid image")
248
-
249
- with BytesIO() as fp:
250
- im.save(fp, "jpeg")
251
- im_data = fp.getvalue()
252
-
253
- return {
254
- "width": im.width,
255
- "height": im.height,
256
- "image": "data:image/jpeg;base64," + b64encode(im_data).decode(),
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 argparse
4
+ from base64 import b64encode
5
+ from collections.abc import AsyncGenerator
6
+ from contextlib import asynccontextmanager, closing
7
+ from io import BytesIO
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import uiautomator2 as u2
12
+ from adbutils import adb
13
+ from anyio import Lock, to_thread
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
+ "shell_command",
22
+ "init",
23
+ "connect",
24
+ "disconnect",
25
+ "disconnect_all",
26
+ "window_size",
27
+ "screenshot",
28
+ "save_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("init", tags={"device:manage"})
59
+ async def init(serial: str = ""):
60
+ """Install essential resources (minicap, minitouch, uiautomator ...) to device.
61
+
62
+ Important:
63
+ This tool must be run on the Android device before running operation actions.
64
+
65
+ Args:
66
+ serial (str): Android device serialno to initialize. If empty string, all devices will be initialized.
67
+
68
+ Returns:
69
+ None upon successful completion
70
+ Raises an exception if the subprocess returns a non-zero exit code.
71
+ """
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]:
128
+ """Connect to an Android device
129
+
130
+ Args:
131
+ serial (str): Android device serial number. If empty string, connects to the unique device if only one device is connected.
132
+
133
+ Returns:
134
+ dict[str,Any]: Device information
135
+ """
136
+ global _devices
137
+ device: u2.Device | None = None
138
+
139
+ logger = get_logger(f"{__name__}.connect")
140
+
141
+ if serial := serial.strip():
142
+ try:
143
+ async with get_device(serial) as device_1:
144
+ # Found, then check if it's still connected
145
+ try:
146
+ return await to_thread.run_sync(lambda: device_1.device_info | device_1.info)
147
+ except u2.ConnectError as e:
148
+ # Found, but not connected, delete it
149
+ logger.warning("Device %s is no longer connected, delete it!", serial)
150
+ del _devices[serial]
151
+ raise e from None
152
+ except KeyError:
153
+ # Not found, need a new connection!
154
+ logger.info("Cannot find device with serial %s, connecting...")
155
+
156
+ # make new connection here!
157
+ async with _global_device_connection_lock:
158
+ device = await to_thread.run_sync(u2.connect, serial)
159
+ if device is None:
160
+ raise RuntimeError("Cannot connect to device")
161
+ logger.info("Connected to device %s", device.serial)
162
+ result = await to_thread.run_sync(lambda: device.device_info | device.info)
163
+ _devices[device.serial] = Lock(), device
164
+ return result
165
+
166
+
167
+ @mcp.tool("disconnect", tags={"device:manage"})
168
+ async def disconnect(serial: str):
169
+ """Disconnect from an Android device
170
+
171
+ Args:
172
+ serial (str): Android device serialno
173
+
174
+ Returns:
175
+ None
176
+ """
177
+ if not (serial := serial.strip()):
178
+ raise ValueError("serial cannot be empty")
179
+ async with _global_device_connection_lock:
180
+ _devices.pop(serial, None)
181
+
182
+
183
+ @mcp.tool("disconnect_all", tags={"device:manage"})
184
+ async def disconnect_all():
185
+ """Disconnect from all Android devices"""
186
+ async with _global_device_connection_lock:
187
+ _devices.clear()
188
+
189
+
190
+ @mcp.tool("window_size", tags={"device:info"})
191
+ async def window_size(serial: str) -> dict[str, int]:
192
+ """Get window size of an Android device
193
+
194
+ Args:
195
+ serial (str): Android device serialno
196
+
197
+ Returns:
198
+ dict[str,int]: Window size object:
199
+ - "width" (int): Window width
200
+ - "height" (int): Window height
201
+ """
202
+ async with get_device(serial) as device:
203
+ width, height = await to_thread.run_sync(device.window_size)
204
+ return {"width": width, "height": height}
205
+
206
+
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]:
209
+ """
210
+ Take screenshot of device
211
+
212
+ Args:
213
+ serial (str): Android device serialno.
214
+ format (str): Image format. Defaults to "jpeg".
215
+ display_id (int): use specific display if device has multiple screen. Defaults to -1.
216
+
217
+ Returns:
218
+ dict[str,Any]: Screenshot image JPEG data with the following keys:
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
254
+ """
255
+ display_id = int(display_id)
256
+
257
+ async with get_device(serial) as device:
258
+ im = await to_thread.run_sync(lambda: device.screenshot(display_id=display_id if display_id >= 0 else None))
259
+
260
+ if not isinstance(im, Image):
261
+ raise RuntimeError("Invalid image")
262
+
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)
270
+
271
+ return file_path.resolve().as_posix()
272
+
273
+
274
+ @mcp.tool("dump_hierarchy", tags={"device:capture"})
275
+ async def dump_hierarchy(serial: str, compressed: bool = False, pretty: bool = False, max_depth: int = -1) -> str:
276
+ """
277
+ Dump window hierarchy
278
+
279
+ Args:
280
+ serial (str): Android device serialno
281
+ compressed (bool): return compressed xml
282
+ pretty (bool): pretty print xml
283
+ max_depth (int): max depth of hierarchy
284
+
285
+ Returns:
286
+ str: xml string of the hierarchy tree
287
+ """
288
+ async with get_device(serial) as device:
289
+ return await to_thread.run_sync(
290
+ lambda: device.dump_hierarchy(compressed=compressed, pretty=pretty, max_depth=max_depth if max_depth > 0 else None)
291
+ )
292
+
293
+
294
+ @mcp.tool("info", tags={"device:info"})
295
+ async def info(serial: str) -> dict[str, Any]:
296
+ """
297
+ Get device info
298
+
299
+ Args:
300
+ serial (str): Android device serialno
301
+
302
+ Returns:
303
+ dict[str,Any]: Device info
304
+ """
305
+
306
+ async with get_device(serial) as device:
307
+ return await to_thread.run_sync(lambda: device.info)