uiautomator2-mcp-server 0.1.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/__init__.py ADDED
File without changes
u2mcp/__main__.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from enum import StrEnum
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ logging.basicConfig(
10
+ level=logging.INFO,
11
+ format="[%(asctime)s] %(levelname)s %(name)s - %(message)s",
12
+ handlers=[logging.StreamHandler()],
13
+ force=True,
14
+ )
15
+
16
+ logging.getLogger("mcp.server").setLevel(logging.WARNING)
17
+ logging.getLogger("sse_starlette").setLevel(logging.WARNING)
18
+ logging.getLogger("docket").setLevel(logging.WARNING)
19
+ logging.getLogger("fakeredis").setLevel(logging.WARNING)
20
+
21
+
22
+ class Transport(StrEnum):
23
+ streamable_http = "streamable-http"
24
+ stdio = "stdio"
25
+ # http = "http"
26
+ # sse = "sse"
27
+
28
+
29
+ def run(
30
+ transport: Annotated[
31
+ Transport, typer.Option("--transport", "-f", help="The transport mechanisms for client-server communication")
32
+ ] = Transport.streamable_http,
33
+ host: Annotated[str | None, typer.Option("--host", "-H", show_default=False, help="Host address for http mode")] = None,
34
+ port: Annotated[int | None, typer.Option("--port", "-p", show_default=False, help="Port number for http mode")] = None,
35
+ ):
36
+ """Run mcp server
37
+ Args:
38
+ transport (Literal["http", "stdio"]): transport type
39
+ host (str | None): host
40
+ port (int | None): port
41
+ """
42
+ from . import tools as _
43
+ from .mcp import mcp
44
+
45
+ if transport == Transport.stdio:
46
+ mcp.run(transport.value)
47
+ elif transport == Transport.streamable_http:
48
+ transport_kwargs = {}
49
+ if host:
50
+ transport_kwargs["host"] = host
51
+ if port:
52
+ transport_kwargs["port"] = port
53
+ mcp.run(transport.value, **transport_kwargs)
54
+ else:
55
+ typer.Abort(f"Unknown transport: {transport}")
56
+
57
+
58
+ def main():
59
+ typer.run(run)
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
u2mcp/mcp.py ADDED
@@ -0,0 +1,18 @@
1
+ """
2
+ This MCP server provides tools for controlling and interacting with Android devices using uiautomator2.
3
+
4
+ It allows you to perform various operations on Android devices such as connecting to devices, taking screenshots,
5
+ getting device information, accessing UI hierarchy, tap on screens, and more...
6
+
7
+ It also provides tools for managing Android applications, such as installing, uninstalling, starting, stopping, and clearing applications.
8
+
9
+ Before performing operations on a device, you need to initialize it using the init tool.
10
+
11
+ All operations require a device serial number to identify the target device.
12
+ """
13
+
14
+ from fastmcp import FastMCP
15
+
16
+ __all__ = ["mcp"]
17
+
18
+ mcp = FastMCP(name="uiautomator2", instructions=__doc__)
u2mcp/py.typed ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ from .action import *
2
+ from .app import *
3
+ from .device import *
u2mcp/tools/action.py ADDED
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from ..mcp import mcp
6
+ from .device import get_device
7
+
8
+ __all__ = (
9
+ "click",
10
+ "long_click",
11
+ "double_click",
12
+ "swipe",
13
+ "swipe_points",
14
+ "drag",
15
+ "press_key",
16
+ "send_text",
17
+ "clear_text",
18
+ "screen_on",
19
+ "screen_off",
20
+ )
21
+
22
+
23
+ @mcp.tool("click")
24
+ async def click(serial: str, x: int, y: int):
25
+ """Click at specific coordinates
26
+
27
+ Args:
28
+ serial (str): Android device serialno
29
+ x (int): X coordinate
30
+ y (int): Y coordinate
31
+ """
32
+ async with get_device(serial) as device:
33
+ await asyncio.to_thread(device.click, x, y)
34
+
35
+
36
+ @mcp.tool("long_click")
37
+ async def long_click(serial: str, x: int, y: int, duration: float = 0.5):
38
+ """Long click at specific coordinates
39
+
40
+ Args:
41
+ serial (str): Android device serialno
42
+ x (int): X coordinate
43
+ y (int): Y coordinate
44
+ duration (float): Duration of the long click in seconds, default is 0.5
45
+ """
46
+ async with get_device(serial) as device:
47
+ await asyncio.to_thread(device.long_click, x, y, duration)
48
+
49
+
50
+ @mcp.tool("double_click")
51
+ async def double_click(serial: str, x: int, y: int, duration: float = 0.1):
52
+ """Double click at specific coordinates
53
+
54
+ Args:
55
+ serial (str): Android device serialno
56
+ x (int): X coordinate
57
+ y (int): Y coordinate
58
+ duration (float): Duration between clicks in seconds, default is 0.1
59
+ """
60
+ async with get_device(serial) as device:
61
+ await asyncio.to_thread(device.double_click, x, y, duration)
62
+
63
+
64
+ @mcp.tool("swipe")
65
+ async def swipe(serial: str, fx: int, fy: int, tx: int, ty: int, duration: float = 0.0, step: int = 0):
66
+ """Swipe from one point to another
67
+
68
+ Args:
69
+ serial (str): Android device serialno
70
+ fx (int): From position X coordinate
71
+ fy (int): From position Y coordinate
72
+ tx (int): To position X coordinate
73
+ ty (int): To position Y coordinate
74
+ duration (float): duration
75
+ steps: 1 steps is about 5ms, if set, duration will be ignore
76
+ """
77
+ async with get_device(serial) as device:
78
+ await asyncio.to_thread(device.swipe, fx, fy, tx, ty, duration if duration > 0 else None, step if step > 0 else None)
79
+
80
+
81
+ @mcp.tool("swipe_points")
82
+ async def swipe_points(serial: str, points: list[tuple[int, int]], duration: float = 0.5):
83
+ """Swipe through multiple points
84
+
85
+ Args:
86
+ serial (str): Android device serialno
87
+ points (list[tuple[int, int]]): List of (x, y) coordinates to swipe through
88
+ duration (float): Duration of swipe in seconds, default is 0.5
89
+ """
90
+ async with get_device(serial) as device:
91
+ await asyncio.to_thread(device.swipe_points, points, duration)
92
+
93
+
94
+ @mcp.tool("drag")
95
+ async def drag(serial: str, sx: int, sy: int, ex: int, ey: int, duration: float = 0.5):
96
+ """Swipe from one point to another point.
97
+
98
+ Args:
99
+ serial (str): Android device serialno
100
+ sx (int): Start X coordinate
101
+ sy (int): Start Y coordinate
102
+ ex (int): End X coordinate
103
+ ey (int): End Y coordinate
104
+ duration (float): Duration of drag in seconds, default is 0.5
105
+ """
106
+ async with get_device(serial) as device:
107
+ await asyncio.to_thread(device.drag, sx, sy, ex, ey, duration)
108
+
109
+
110
+ @mcp.tool("press_key")
111
+ async def press_key(serial: str, key: str):
112
+ """Press a key
113
+
114
+ Args:
115
+ serial (str): Android device serialno
116
+ key (str): Key to press.
117
+ Supported key name includes:
118
+ home, back, left, right, up, down, center, menu, search, enter,
119
+ delete(or del), recent(recent apps), volume_up, volume_down,
120
+ volume_mute, camera, power
121
+ """
122
+ async with get_device(serial) as device:
123
+ await asyncio.to_thread(device.press, key)
124
+
125
+
126
+ @mcp.tool("send_text")
127
+ async def send_text(serial: str, text: str, clear: bool = False):
128
+ """Send text to the current input field
129
+
130
+ Args:
131
+ serial (str): Android device serialno
132
+ text (str): input text
133
+ clear: clear text before input
134
+ """
135
+ async with get_device(serial) as device:
136
+ await asyncio.to_thread(device.send_keys, text, clear)
137
+
138
+
139
+ @mcp.tool("clear_text")
140
+ async def clear_text(serial: str):
141
+ """Clear text in the current input field
142
+
143
+ Args:
144
+ serial (str): Android device serialno
145
+ """
146
+ async with get_device(serial) as device:
147
+ await asyncio.to_thread(device.clear_text)
148
+
149
+
150
+ @mcp.tool("screen_on")
151
+ async def screen_on(serial: str):
152
+ """Turn screen on
153
+
154
+ Args:
155
+ serial (str): Android device serialno
156
+ """
157
+ async with get_device(serial) as device:
158
+ await asyncio.to_thread(device.screen_on)
159
+
160
+
161
+ @mcp.tool("screen_off")
162
+ async def screen_off(serial: str):
163
+ """Turn screen off
164
+
165
+ Args:
166
+ serial (str): Android device serialno
167
+ """
168
+ async with get_device(serial) as device:
169
+ await asyncio.to_thread(device.screen_off)
u2mcp/tools/app.py ADDED
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ from ..mcp import mcp
7
+ from .device import get_device
8
+
9
+ __all__ = (
10
+ "app_install",
11
+ "app_uninstall",
12
+ "app_uninstall_all",
13
+ "app_start",
14
+ "app_stop",
15
+ "app_stop_all",
16
+ "app_clear",
17
+ "app_info",
18
+ "app_current",
19
+ "app_list",
20
+ "app_list_running",
21
+ "app_auto_grant_permissions",
22
+ )
23
+
24
+
25
+ @mcp.tool("app_install")
26
+ async def app_install(serial: str, data: str):
27
+ """Install app
28
+
29
+ Args:
30
+ serial (str): Android device serialno
31
+ data (str): APK file path or url
32
+ """
33
+ async with get_device(serial) as device:
34
+ await asyncio.to_thread(device.app_install, data)
35
+
36
+
37
+ @mcp.tool("app_uninstall")
38
+ async def app_uninstall(serial: str, package_name: str) -> bool:
39
+ """Uninstall an app
40
+
41
+ Args:
42
+ serial (str): Android device serialno
43
+ package_name (str): package name
44
+
45
+ Returns:
46
+ bool: success
47
+ """
48
+ async with get_device(serial) as device:
49
+ return await asyncio.to_thread(device.app_uninstall, package_name)
50
+
51
+
52
+ @mcp.tool("app_uninstall_all")
53
+ async def app_uninstall_all(serial: str, excludes: list[str] | None = None) -> list[str]:
54
+ """Uninstall all apps
55
+
56
+ Args:
57
+ serial (str): Android device serialno
58
+ excludes (list[str] | None): packages that do not want to uninstall
59
+
60
+ Returns:
61
+ list[str]: list of uninstalled apps
62
+ """
63
+ async with get_device(serial) as device:
64
+ return await asyncio.to_thread(device.app_uninstall_all, excludes or [])
65
+
66
+
67
+ @mcp.tool("app_start")
68
+ async def app_start(
69
+ serial: str,
70
+ package_name: str,
71
+ activity: str | None = None,
72
+ wait: bool = False,
73
+ stop: bool = False,
74
+ ):
75
+ """Launch application
76
+
77
+ Args:
78
+ serial (str): Android device serialno
79
+ package_name (str): package name
80
+ activity (str): app activity
81
+ stop (bool): Stop app before starting the activity. (require activity)
82
+ wait (bool): wait until app started. default False
83
+ """
84
+ async with get_device(serial) as device:
85
+ await asyncio.to_thread(device.app_start, package_name, activity, wait, stop)
86
+
87
+
88
+ @mcp.tool("app_wait")
89
+ async def app_wait(serial: str, package_name: str, timeout: float = 20.0, front=False):
90
+ """Wait until app launched
91
+
92
+ Args:
93
+ serial (str): Android device serialno
94
+ package_name (str): package name
95
+ timeout (float): maximum wait time seconds
96
+ front (bool): wait until app is current app
97
+ """
98
+ async with get_device(serial) as device:
99
+ if not await asyncio.to_thread(device.app_wait, package_name, timeout, front):
100
+ raise RuntimeError(f"Failed to wait App {package_name} to launch")
101
+
102
+
103
+ @mcp.tool("app_stop")
104
+ async def app_stop(serial: str, package_name: str):
105
+ """Stop one application
106
+
107
+ Args:
108
+ serial (str): Android device serialno
109
+ package_name (str): package name
110
+ """
111
+ async with get_device(serial) as device:
112
+ await asyncio.to_thread(device.app_stop, package_name)
113
+
114
+
115
+ @mcp.tool("app_stop_all")
116
+ async def app_stop_all(serial: str, excludes: list[str] | None = None) -> list[str]:
117
+ """Stop all third party applications
118
+
119
+ Args:
120
+ excludes (list): apps that do now want to kill
121
+
122
+ Returns:
123
+ list[str]: a list of killed apps
124
+ """
125
+ async with get_device(serial) as device:
126
+ return await asyncio.to_thread(device.app_stop_all, excludes or [])
127
+
128
+
129
+ @mcp.tool("app_clear")
130
+ async def app_clear(serial: str, package_name: str):
131
+ """Stop and clear app data: pm clear
132
+
133
+ Args:
134
+ serial (str): Android device serialno
135
+ package_name (str): package name
136
+
137
+ Returns:
138
+ bool: success
139
+ """
140
+ async with get_device(serial) as device:
141
+ await asyncio.to_thread(device.app_clear, package_name)
142
+
143
+
144
+ @mcp.tool("app_info")
145
+ async def app_info(serial: str, package_name: str) -> dict[str, Any]:
146
+ """
147
+ Get app info
148
+
149
+ Args:
150
+ serial (str): Android device serialno
151
+ package_name (str): package name
152
+
153
+ Returns:
154
+ dict[str,Any]: app info
155
+
156
+ Example:
157
+ {"versionName": "1.1.7", "versionCode": 1001007}
158
+ """
159
+ async with get_device(serial) as device:
160
+ return await asyncio.to_thread(device.app_info, package_name)
161
+
162
+
163
+ @mcp.tool("app_current")
164
+ async def app_current(serial: str) -> dict[str, Any]:
165
+ """
166
+ Get current app info
167
+
168
+ Args:
169
+ serial (str): Android device serialno
170
+
171
+ Returns:
172
+ dict[str,Any]: running app info
173
+ """
174
+ async with get_device(serial) as device:
175
+ return await asyncio.to_thread(device.app_current)
176
+
177
+
178
+ @mcp.tool("app_list")
179
+ async def app_list(serial: str, filter: str = "") -> list[str]:
180
+ """
181
+ List installed app package names
182
+
183
+ Args:
184
+ serial (str): Android device serialno
185
+ filter (str): [-f] [-d] [-e] [-s] [-3] [-i] [-u] [--user USER_ID] [FILTER]
186
+
187
+ Returns:
188
+ list[str]: list of apps by filter
189
+ """
190
+ async with get_device(serial) as device:
191
+ return await asyncio.to_thread(device.app_list, filter.strip())
192
+
193
+
194
+ @mcp.tool("app_list_running")
195
+ async def app_list_running(serial: str) -> list[str]:
196
+ """
197
+ List running apps
198
+
199
+ Args:
200
+ serial (str): Android device serialno
201
+
202
+ Returns:
203
+ list[str]: list of running apps
204
+ """
205
+ async with get_device(serial) as device:
206
+ return await asyncio.to_thread(device.app_list_running)
207
+
208
+
209
+ @mcp.tool("app_auto_grant_permissions")
210
+ async def app_auto_grant_permissions(serial: str, package_name: str):
211
+ """auto grant permissions
212
+
213
+ Args:
214
+ serial (str): Android device serialno
215
+ package_name (str): package name
216
+
217
+ Help of "adb shell pm":
218
+ grant [--user USER_ID] PACKAGE PERMISSION
219
+ revoke [--user USER_ID] PACKAGE PERMISSION
220
+ These commands either grant or revoke permissions to apps. The permissions
221
+ must be declared as used in the app's manifest, be runtime permissions
222
+ (protection level dangerous), and the app targeting SDK greater than Lollipop MR1 (API level 22).
223
+
224
+ Help of "Android official pm" see <https://developer.android.com/tools/adb#pm>
225
+ Grant a permission to an app. On devices running Android 6.0 (API level 23) and higher,
226
+ the permission can be any permission declared in the app manifest.
227
+ On devices running Android 5.1 (API level 22) and lower,
228
+ must be an optional permission defined by the app.
229
+ """
230
+ async with get_device(serial) as device:
231
+ await asyncio.to_thread(device.app_auto_grant_permissions, package_name)
u2mcp/tools/device.py ADDED
@@ -0,0 +1,290 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import sys
5
+ from base64 import b64encode
6
+ from contextlib import asynccontextmanager
7
+ from io import BytesIO
8
+ from typing import Any, Literal
9
+
10
+ import uiautomator2 as u2
11
+ from adbutils import adb
12
+ from fastmcp.dependencies import CurrentContext
13
+ from fastmcp.server.context import 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):
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 = "", ctx: Context = CurrentContext()):
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
+ while True:
114
+ tag, line = await output_queue.get()
115
+
116
+ if not line: # This was the EOF sentinel (empty string)
117
+ completed_streams += 1
118
+ if completed_streams == len(tasks):
119
+ output_queue.task_done()
120
+ break # Both streams are done, exit the main consumer loop
121
+
122
+ # Process the actual line data
123
+ if line := line.strip():
124
+ logger.info("%s: %s", tag, line)
125
+ if tag == "stdout":
126
+ await ctx.info(line)
127
+ else:
128
+ await ctx.warning(line)
129
+
130
+ output_queue.task_done()
131
+
132
+ # Wait for the tasks to formally complete and the process to exit
133
+ logger.info("waiting for uiautomator2 init command to complete")
134
+ await asyncio.gather(*tasks)
135
+ exit_code = await process.wait()
136
+ logger.info("uiautomator2 init command exited with code: %s", exit_code)
137
+ if exit_code != 0:
138
+ raise RuntimeError(f"uiautomator2 init command exited with non-zero code: {exit_code}")
139
+
140
+
141
+ @mcp.tool("connect")
142
+ async def connect(serial: str = ""):
143
+ """Connect to an Android device
144
+
145
+ Args:
146
+ serial (str): Android device serial number. If empty string, connects to the unique device if only one device is connected.
147
+
148
+
149
+ Returns:
150
+ dict[str,Any]: Device information
151
+ """
152
+ global _devices
153
+ device: u2.Device | None = None
154
+
155
+ logger = get_logger(f"{__name__}.connect")
156
+
157
+ if serial := serial.strip():
158
+ try:
159
+ async with get_device(serial) as device:
160
+ # Found, then check if it's still connected
161
+ try:
162
+ return await asyncio.to_thread(lambda: device.device_info | device.info)
163
+ except u2.ConnectError as e:
164
+ # Found, but not connected, delete it
165
+ logger.warning("Device %s is no longer connected, delete it!", serial)
166
+ del _devices[serial]
167
+ raise e from None
168
+ except KeyError:
169
+ # Not found, need a new connection!
170
+ logger.info("Cannot find device with serial %s, connecting...")
171
+
172
+ # make new connection here!
173
+ async with _device_connect_lock:
174
+ device = await asyncio.to_thread(u2.connect, serial)
175
+ logger.info("Connected to device %s", device.serial)
176
+ result = await asyncio.to_thread(lambda: device.device_info | device.info)
177
+ _devices[device.serial] = asyncio.Semaphore(), device
178
+ return result
179
+
180
+
181
+ @mcp.tool("disconnect")
182
+ async def disconnect(serial: str):
183
+ """Disconnect from an Android device
184
+
185
+ Args:
186
+ serial (str): Android device serialno
187
+
188
+ Returns:
189
+ None
190
+ """
191
+ if not (serial := serial.strip()):
192
+ raise ValueError("serial cannot be empty")
193
+ async with _device_connect_lock:
194
+ del _devices[serial]
195
+
196
+
197
+ @mcp.tool("disconnect_all")
198
+ async def disconnect_all():
199
+ """Disconnect from all Android devices"""
200
+ async with _device_connect_lock:
201
+ _devices.clear()
202
+
203
+
204
+ @mcp.tool("window_size")
205
+ async def window_size(serial: str) -> dict[str, int]:
206
+ """Get window size of an Android device
207
+
208
+ Args:
209
+ serial (str): Android device serialno
210
+
211
+ Returns:
212
+ dict[str,int]: Window size object:
213
+ - "width" (int): Window width
214
+ - "height" (int): Window height
215
+ """
216
+ async with get_device(serial) as device:
217
+ width, height = await asyncio.to_thread(device.window_size)
218
+ return {"width": width, "height": height}
219
+
220
+
221
+ @mcp.tool("screenshot")
222
+ async def screenshot(serial: str, display_id: int = -1) -> dict[str, Any]:
223
+ """
224
+ Take screenshot of device
225
+
226
+ Args:
227
+ serial (str): Android device serialno
228
+ display_id (int): use specific display if device has multiple screen. Defaults to -1.
229
+
230
+ Returns:
231
+ dict[str,Any]: Screenshot image JPEG data with the following keys:
232
+ - "image" (str): Base64 encoded image data in data URL format (data:image/jpeg;base64,...)
233
+ - "size" (tuple[int,int]): Image dimensions as (width, height)
234
+ """
235
+ display_id = int(display_id)
236
+ async with get_device(serial) as device:
237
+ im = await asyncio.to_thread(
238
+ device.screenshot,
239
+ display_id=display_id if display_id >= 0 else None,
240
+ ) # type: ignore[arg-type]
241
+
242
+ if not isinstance(im, Image):
243
+ raise RuntimeError("Invalid image")
244
+
245
+ with BytesIO() as fp:
246
+ im.save(fp, "jpeg")
247
+ im_data = fp.getvalue()
248
+
249
+ return {
250
+ "width": im.width,
251
+ "height": im.height,
252
+ "image": "data:image/jpeg;base64," + b64encode(im_data).decode(),
253
+ }
254
+
255
+
256
+ @mcp.tool("dump_hierarchy")
257
+ async def dump_hierarchy(serial: str, compressed: bool = False, pretty: bool = False, max_depth: int = -1) -> str:
258
+ """
259
+ Dump window hierarchy
260
+
261
+ Args:
262
+ serial (str): Android device serialno
263
+ compressed (bool): return compressed xml
264
+ pretty (bool): pretty print xml
265
+ max_depth (int): max depth of hierarchy
266
+
267
+ Returns:
268
+ str: xml string of the hierarchy tree
269
+ """
270
+ async with get_device(serial) as device:
271
+ return await asyncio.to_thread(
272
+ device.dump_hierarchy, compressed=compressed, pretty=pretty, max_depth=max_depth if max_depth > 0 else None
273
+ )
274
+
275
+
276
+ @mcp.tool("info")
277
+ async def info(serial: str) -> dict[str, Any]:
278
+ """
279
+ Get device info
280
+
281
+ Args:
282
+ serial (str): Android device serialno
283
+
284
+ Returns:
285
+ dict[str,Any]: Device info
286
+ """
287
+
288
+ async with get_device(serial) as device:
289
+ return await asyncio.to_thread(lambda: device.info)
290
+
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.3
2
+ Name: uiautomator2-mcp-server
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: tanbro
6
+ Author-email: tanbro <tanbro@163.com>
7
+ Requires-Dist: fastmcp>=2.14.2,<3.0
8
+ Requires-Dist: uiautomator2>=3.5.0,<4.0
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+
@@ -0,0 +1,12 @@
1
+ u2mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ u2mcp/__main__.py,sha256=-Vd3rfbMDPvY-TNgyntRRG-_cMj7kxgsag_df3gAe_w,1769
3
+ u2mcp/mcp.py,sha256=j8b_G2d8QX6VdjsUqwmeD1Eu9Sc79BlBWvKAmhTQOK8,721
4
+ u2mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ u2mcp/tools/__init__.py,sha256=tKfsoF6tYQSapfGC1ybEcU4S77cipyfO5tYR6u0eAlI,66
6
+ u2mcp/tools/action.py,sha256=A9dCED1NiKOJpw9lBewOQ9YQvfGOkK8bEL1L9FLjCQg,5088
7
+ u2mcp/tools/app.py,sha256=k_9ROOdEbHOFWbh0sO6pL1x4YN0NjvhcnHcXfaN9Rvw,6578
8
+ u2mcp/tools/device.py,sha256=NBNt210Sx9Ffbw0luUL69k_SAKUOCVsI313BQ5FbtTs,9077
9
+ uiautomator2_mcp_server-0.1.0.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
10
+ uiautomator2_mcp_server-0.1.0.dist-info/entry_points.txt,sha256=uIp3lo8qa_IGxe_Wx-3sReaooXzhvViP7b5YS-RPMPY,47
11
+ uiautomator2_mcp_server-0.1.0.dist-info/METADATA,sha256=4WF7HSKeaKsU9LD-OQRKQl65WiUKg0yZIGpT8lnAJmY,296
12
+ uiautomator2_mcp_server-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.18
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ u2mcp = u2mcp.__main__:main
3
+