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/tools/device.py CHANGED
@@ -1,293 +1,259 @@
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 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)