uiautomator2-mcp-server 0.1.1__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/.gitignore CHANGED
@@ -1 +1 @@
1
- _version.py
1
+ _version.py
u2mcp/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- from . import _version as version
2
- from ._version import __commit_id__, __version__, __version_tuple__
1
+ from . import _version as version
2
+ from ._version import __commit_id__, __version__, __version_tuple__
u2mcp/__main__.py CHANGED
@@ -1,60 +1,82 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import logging
5
- from typing import Annotated, Any, Awaitable
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
- def run(
23
- http: Annotated[bool, typer.Option("--http", "-h", help="Run mcp server in streamable http mode")] = False,
24
- stdio: Annotated[bool, typer.Option("--stdio", "-s", help="Run mcp server in stdio mode")] = False,
25
- host: Annotated[str | None, typer.Option("--host", "-H", show_default=False, help="Host address for http mode")] = None,
26
- port: Annotated[int | None, typer.Option("--port", "-p", show_default=False, help="Port number for http mode")] = None,
27
- log_level: Annotated[str | None, typer.Option("--log-level", "-l", help="Log level")] = None,
28
- ):
29
- """Run uiautomator2 mcp server"""
30
- if not http and not stdio:
31
- typer.Abort("Please specify one of ‘--http or ‘--stdio’")
32
-
33
- from . import tools as _
34
- from .mcp import mcp
35
-
36
- awaitables: list[Awaitable] = []
37
-
38
- if http:
39
- transport_kwargs: dict[str, Any] = {}
40
- if host:
41
- transport_kwargs["host"] = host
42
- if port:
43
- transport_kwargs["port"] = port
44
- awaitables.append(mcp.run_http_async(transport="streamable-http", **transport_kwargs, log_level=log_level))
45
-
46
- if stdio:
47
- awaitables.append(mcp.run_stdio_async(log_level=log_level))
48
-
49
- async def _run():
50
- await asyncio.gather(*awaitables)
51
-
52
- asyncio.run(_run())
53
-
54
-
55
- def main():
56
- typer.run(run)
57
-
58
-
59
- if __name__ == "__main__":
60
- main()
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ import secrets
6
+ from typing import Annotated, Any, Literal
7
+
8
+ import typer
9
+
10
+ from .mcp import make_mcp
11
+
12
+
13
+ def run(
14
+ transport: Annotated[
15
+ Literal["http", "stdio"], typer.Argument(help="Run mcp server on streamable-http http or stdio transport")
16
+ ] = "stdio",
17
+ host: Annotated[
18
+ str, typer.Option("--host", "-H", show_default=False, help="Host address of streamable-http transport")
19
+ ] = "127.0.0.1",
20
+ port: Annotated[
21
+ int, typer.Option("--port", "-p", show_default=False, help="Port number of streamable-http transport")
22
+ ] = 8000,
23
+ json_response: Annotated[bool, typer.Option("--json-response", "-j", help="Whether to use JSON response format")] = True,
24
+ log_level: Annotated[
25
+ Literal["debug", "info", "warning", "error", "critical"], typer.Option("--log-level", "-l", help="Log level")
26
+ ] = "info",
27
+ no_token: Annotated[
28
+ bool,
29
+ typer.Option(
30
+ "--no-token",
31
+ help="Disable authentication bearer token verification of streamable-http transport. If not set, a token will be generated randomly.",
32
+ ),
33
+ ] = False,
34
+ token: Annotated[
35
+ str | None,
36
+ typer.Option("--token", "-t", help="Explicit set token of streamable-http authentication"),
37
+ ] = None,
38
+ ):
39
+ """Run uiautomator2 mcp server"""
40
+ logging.basicConfig(
41
+ level=log_level.upper(),
42
+ format="[%(asctime)s] %(levelname)s %(name)s - %(message)s",
43
+ handlers=[logging.StreamHandler()],
44
+ force=True,
45
+ )
46
+ logging.getLogger("mcp.server").setLevel(logging.WARNING)
47
+ logging.getLogger("sse_starlette").setLevel(logging.WARNING)
48
+ logging.getLogger("docket").setLevel(logging.WARNING)
49
+ logging.getLogger("fakeredis").setLevel(logging.WARNING)
50
+
51
+ run_kwargs: dict[str, Any] = {"json_response": json_response, "log_level": log_level}
52
+
53
+ if transport == "http":
54
+ run_kwargs["transport"] = "streamable-http"
55
+ if host:
56
+ run_kwargs["host"] = host
57
+ if port:
58
+ run_kwargs["port"] = port
59
+ if token:
60
+ token = token.strip()
61
+ if not re.match(r"^[a-zA-Z0-9\-_.~!$&'()*+,;=:@]{8,64}$", token):
62
+ raise typer.BadParameter("Token must be 8-64 characters long and can only contain URL-safe characters")
63
+ elif not no_token:
64
+ token = secrets.token_urlsafe()
65
+ mcp = make_mcp(token)
66
+ else:
67
+ run_kwargs["transport"] = "stdio"
68
+ mcp = make_mcp()
69
+
70
+ # can NOT import tools until mcp is crated
71
+ from . import tools as _
72
+
73
+ # run mcp
74
+ mcp.run(**run_kwargs)
75
+
76
+
77
+ def main():
78
+ typer.run(run)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
u2mcp/_version.py CHANGED
@@ -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.1'
32
- __version_tuple__ = version_tuple = (0, 1, 1)
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.1.3'
32
+ __version_tuple__ = version_tuple = (0, 1, 3)
33
+
34
+ __commit_id__ = commit_id = None
u2mcp/mcp.py CHANGED
@@ -1,18 +1,79 @@
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__)
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
+ import sys
15
+ from contextlib import asynccontextmanager
16
+ from functools import partial
17
+ from textwrap import dedent
18
+ from typing import Any
19
+
20
+ if sys.version_info >= (3, 12): # qa: noqa
21
+ from typing import override
22
+ else: # qa: noqa
23
+ from typing_extensions import override
24
+
25
+ from fastmcp import FastMCP
26
+ from fastmcp.server.auth import AccessToken, AuthProvider
27
+ from pydantic import AnyHttpUrl
28
+ from rich.console import Console
29
+ from rich.markdown import Markdown
30
+
31
+ __all__ = ["mcp", "make_mcp"]
32
+
33
+
34
+ # Warning: You can NOT import it unless call `make_mcp()`
35
+ mcp: FastMCP
36
+
37
+
38
+ @asynccontextmanager
39
+ async def _lifespan(instance: FastMCP, token: str | None):
40
+ content = Markdown(
41
+ dedent(f"""
42
+ ------
43
+
44
+ Server configured with **authentication token**. Connect using this token in the Authorization header:
45
+
46
+ `Authorization: Bearer {token}`
47
+
48
+ ------
49
+ """)
50
+ )
51
+ Console(stderr=True).print(content)
52
+ yield
53
+
54
+
55
+ class _SimpleTokenAuthProvider(AuthProvider):
56
+ @override
57
+ def __init__(
58
+ self,
59
+ base_url: AnyHttpUrl | str | None = None,
60
+ required_scopes: list[str] | None = ["mcp:tools"],
61
+ token: str | None = None,
62
+ ):
63
+ super().__init__(base_url, required_scopes)
64
+ self.token = token
65
+
66
+ @override
67
+ async def verify_token(self, token: str) -> AccessToken | None:
68
+ if self.token == token:
69
+ return AccessToken(token=token, client_id="user", scopes=self.required_scopes)
70
+ return None
71
+
72
+
73
+ def make_mcp(token: str | None = None) -> FastMCP:
74
+ global mcp
75
+ params: dict[str, Any] = dict(name="uiautomator2", instructions=__doc__)
76
+ if token:
77
+ params.update(lifespan=partial(_lifespan, token=token), auth=_SimpleTokenAuthProvider(token=token))
78
+ mcp = FastMCP(**params)
79
+ return mcp
u2mcp/tools/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
- from .action import *
2
- from .app import *
3
- from .device import *
1
+ from .action import *
2
+ from .app import *
3
+ from .device import *
4
+ from .misc import *
u2mcp/tools/action.py CHANGED
@@ -1,169 +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)
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
+ "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 to_thread.run_sync(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 to_thread.run_sync(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 to_thread.run_sync(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 to_thread.run_sync(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 to_thread.run_sync(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 to_thread.run_sync(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 to_thread.run_sync(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 to_thread.run_sync(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 to_thread.run_sync(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 to_thread.run_sync(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 to_thread.run_sync(device.screen_off)