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/mcp.py CHANGED
@@ -1,61 +1,172 @@
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 contextlib import asynccontextmanager
15
- from textwrap import dedent
16
- from typing import Any
17
-
18
- from fastmcp import FastMCP
19
- from fastmcp.server.auth import AccessToken, AuthProvider
20
- from rich.console import Console
21
- from rich.markdown import Markdown
22
-
23
- __all__ = ["mcp"]
24
-
25
- _params: dict[str, Any] = {}
26
-
27
-
28
- def update_params(**kwargs):
29
- global _params
30
- _params.update(kwargs)
31
-
32
-
33
- @asynccontextmanager
34
- async def _lifespan(instance: FastMCP):
35
- if _params.get("transport") == "http" and (token := _params.get("token")):
36
- content = dedent(f"""
37
- ------
38
-
39
- **Server configured with authentication token. Connect using this token in the Authorization header:**
40
-
41
- `Authorization: Bearer {token}`
42
-
43
- ------
44
- """).strip()
45
- Console().print(Markdown(content))
46
-
47
- yield
48
-
49
-
50
- class _SimpleTokenAuthProvider(AuthProvider):
51
- _scopes = ["mcp:tools"]
52
-
53
- async def verify_token(self, token: str) -> AccessToken | None:
54
- if server_token := _params.get("token"):
55
- if token == server_token:
56
- return AccessToken(token=token, client_id="user", scopes=self._scopes)
57
- return None
58
- return AccessToken(token=token, client_id="user", scopes=self._scopes)
59
-
60
-
61
- mcp = FastMCP(name="uiautomator2", instructions=__doc__, lifespan=_lifespan, auth=_SimpleTokenAuthProvider())
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 __future__ import annotations
15
+
16
+ import fnmatch
17
+ import sys
18
+ from contextlib import asynccontextmanager
19
+ from functools import partial
20
+ from textwrap import dedent
21
+ from typing import Any
22
+
23
+ from anyio import create_task_group
24
+ from fastmcp import FastMCP
25
+ from fastmcp.server.auth import AccessToken, AuthProvider
26
+ from pydantic import AnyHttpUrl
27
+ from rich.console import Console
28
+ from rich.markdown import Markdown
29
+
30
+ from .background import set_background_task_group
31
+ from .helpers import print_tags
32
+ from .middlewares import EmptyResponseMiddleware
33
+
34
+ if sys.version_info >= (3, 12): # qa: noqa
35
+ from typing import override
36
+ else: # qa: noqa
37
+ from typing_extensions import override
38
+
39
+ __all__ = ["mcp", "make_mcp"]
40
+
41
+
42
+ # Warning: You can NOT import it unless call `make_mcp()`
43
+ mcp: FastMCP
44
+
45
+
46
+ def _parse_tags(tags: str | None) -> set[str] | None:
47
+ """Parse comma-separated tags string into a set."""
48
+ if not tags:
49
+ return None
50
+ return {tag.strip() for tag in tags.split(",") if tag.strip()}
51
+
52
+
53
+ def _expand_wildcards(tags: set[str] | None, all_available_tags: set[str] | None) -> set[str] | None:
54
+ """Expand wildcard patterns in tags.
55
+
56
+ Supports:
57
+ - * matches any characters
58
+ - ? matches exactly one character
59
+ - device:* matches all device:* tags
60
+ - *:shell matches all shell tags (device:shell, etc.)
61
+
62
+ Examples:
63
+ device:* -> device:manage, device:info, device:capture, device:shell
64
+ *:shell -> device:shell
65
+ action:to* -> action:touch, action:tool (if exists)
66
+ """
67
+ if not tags or not all_available_tags:
68
+ return None
69
+
70
+ expanded = set()
71
+
72
+ for tag in tags:
73
+ if "*" in tag or "?" in tag:
74
+ # Use fnmatch for wildcard matching
75
+ for existing_tag in all_available_tags:
76
+ if fnmatch.fnmatch(existing_tag, tag):
77
+ expanded.add(existing_tag)
78
+ else:
79
+ # No wildcard, add as-is
80
+ expanded.add(tag)
81
+
82
+ return expanded if expanded else None
83
+
84
+
85
+ @asynccontextmanager
86
+ async def _lifespan(instance: FastMCP, /, show_tags: bool = True, token: str | None = None):
87
+ console = Console(stderr=True)
88
+
89
+ # Show enabled tags and tools if requested
90
+ if show_tags:
91
+ console.print("\n[bold cyan]Enabled Tags and Tools:[/bold cyan]")
92
+ await print_tags(instance, console)
93
+ console.print("")
94
+
95
+ if token:
96
+ content = Markdown(
97
+ dedent(f"""
98
+ ------
99
+
100
+ Server configured with **authentication token**. Connect using this token in the Authorization header:
101
+
102
+ `Authorization: Bearer {token}`
103
+
104
+ ------
105
+ """)
106
+ )
107
+ console.print(content)
108
+
109
+ # Global task group for background tasks - keeps running until server shuts down
110
+ async with create_task_group() as tg:
111
+ set_background_task_group(tg)
112
+ yield
113
+
114
+
115
+ class _SimpleTokenAuthProvider(AuthProvider):
116
+ @override
117
+ def __init__(
118
+ self,
119
+ base_url: AnyHttpUrl | str | None = None,
120
+ required_scopes: list[str] | None = ["mcp:tools"],
121
+ token: str | None = None,
122
+ ):
123
+ super().__init__(base_url, required_scopes)
124
+ self.token = token
125
+
126
+ @override
127
+ async def verify_token(self, token: str) -> AccessToken | None:
128
+ if self.token == token:
129
+ return AccessToken(token=token, client_id="user", scopes=self.required_scopes)
130
+ return None
131
+
132
+
133
+ def make_mcp(
134
+ token: str | None = None,
135
+ include_tags: str | None = None,
136
+ exclude_tags: str | None = None,
137
+ show_tags: bool = False,
138
+ fix_empty_responses: bool = False,
139
+ ) -> FastMCP:
140
+ global mcp
141
+ params: dict[str, Any] = dict(name="uiautomator2", instructions=__doc__)
142
+ lifespan_kwargs: dict[str, Any] = {"show_tags": show_tags}
143
+ if token:
144
+ lifespan_kwargs["token"] = token
145
+ params.update(lifespan=partial(_lifespan, **lifespan_kwargs), auth=_SimpleTokenAuthProvider(token=token))
146
+ else:
147
+ params.update(lifespan=partial(_lifespan, **lifespan_kwargs))
148
+ mcp = FastMCP(**params)
149
+
150
+ # Add middleware to fix empty responses if enabled
151
+ if fix_empty_responses:
152
+ mcp.add_middleware(EmptyResponseMiddleware())
153
+
154
+ # Import tools to register them with the MCP (needed for wildcard expansion)
155
+ from . import tools as _ # noqa: F401
156
+
157
+ # Collect all available tags from registered tools for wildcard expansion
158
+ all_tag_set: set[str] = set()
159
+ for tool in mcp._tool_manager._tools.values():
160
+ all_tag_set.update(tool.tags or [])
161
+
162
+ # Parse and expand tag filters
163
+ parsed_include_tags = _expand_wildcards(_parse_tags(include_tags), all_tag_set)
164
+ parsed_exclude_tags = _expand_wildcards(_parse_tags(exclude_tags), all_tag_set)
165
+
166
+ # Set tag filters on the MCP instance
167
+ if parsed_include_tags is not None:
168
+ mcp.include_tags = parsed_include_tags
169
+ if parsed_exclude_tags is not None:
170
+ mcp.exclude_tags = parsed_exclude_tags
171
+
172
+ return mcp
u2mcp/middlewares.py ADDED
@@ -0,0 +1,40 @@
1
+ """Middleware to fix empty responses for Zhipu AI compatibility."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
6
+ from fastmcp.tools.tool import ToolResult
7
+ from mcp.types import CallToolRequestParams, TextContent
8
+
9
+
10
+ class EmptyResponseMiddleware(Middleware):
11
+ """Middleware that converts empty tool responses to non-empty values.
12
+
13
+ This is needed for compatibility with ZhiPu AI model service, which doesn't handle empty responses (content: []) correctly.
14
+
15
+ When enabled, this middleware will convert empty content to "" (empty string).
16
+ """
17
+
18
+ async def on_call_tool(
19
+ self,
20
+ context: MiddlewareContext[CallToolRequestParams],
21
+ call_next: CallNext[CallToolRequestParams, ToolResult],
22
+ ) -> ToolResult:
23
+ """Intercept tool calls and fix empty responses."""
24
+ result = await call_next(context)
25
+
26
+ # Convert empty content to empty string for ZhiPu compatibility
27
+ if isinstance(result, ToolResult):
28
+ if not result.content:
29
+ # Empty content - return empty string
30
+ result.content = [TextContent(type="text", text="")]
31
+ elif (
32
+ isinstance(result.content, list)
33
+ and len(result.content) == 1
34
+ and isinstance(result.content[0], dict)
35
+ and not result.content[0].get("text")
36
+ ):
37
+ # Missing/empty text - set to empty string
38
+ result.content[0]["text"] = ""
39
+
40
+ return result
u2mcp/tools/__init__.py CHANGED
@@ -1,3 +1,8 @@
1
- from .action import *
2
- from .app import *
3
- from .device import *
1
+ from .action import *
2
+ from .app import *
3
+ from .clipboard import *
4
+ from .device import *
5
+ from .element import *
6
+ from .input import *
7
+ from .misc import *
8
+ from .scrcpy import *
u2mcp/tools/action.py CHANGED
@@ -1,169 +1,143 @@
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
+ "screen_on",
17
+ "screen_off",
18
+ )
19
+
20
+
21
+ @mcp.tool("click", tags={"action:touch"})
22
+ async def click(serial: str, x: int, y: int):
23
+ """Click at specific coordinates
24
+
25
+ Args:
26
+ serial (str): Android device serialno
27
+ x (int): X coordinate
28
+ y (int): Y coordinate
29
+ """
30
+ async with get_device(serial) as device:
31
+ await to_thread.run_sync(device.click, x, y)
32
+
33
+
34
+ @mcp.tool("long_click", tags={"action:touch"})
35
+ async def long_click(serial: str, x: int, y: int, duration: float = 0.5):
36
+ """Long click at specific coordinates
37
+
38
+ Args:
39
+ serial (str): Android device serialno
40
+ x (int): X coordinate
41
+ y (int): Y coordinate
42
+ duration (float): Duration of the long click in seconds, default is 0.5
43
+ """
44
+ async with get_device(serial) as device:
45
+ await to_thread.run_sync(device.long_click, x, y, duration)
46
+
47
+
48
+ @mcp.tool("double_click", tags={"action:touch"})
49
+ async def double_click(serial: str, x: int, y: int, duration: float = 0.1):
50
+ """Double click at specific coordinates
51
+
52
+ Args:
53
+ serial (str): Android device serialno
54
+ x (int): X coordinate
55
+ y (int): Y coordinate
56
+ duration (float): Duration between clicks in seconds, default is 0.1
57
+ """
58
+ async with get_device(serial) as device:
59
+ await to_thread.run_sync(device.double_click, x, y, duration)
60
+
61
+
62
+ @mcp.tool("swipe", tags={"action:gesture"})
63
+ async def swipe(serial: str, fx: int, fy: int, tx: int, ty: int, duration: float = 0.0, step: int = 0):
64
+ """Swipe from one point to another
65
+
66
+ Args:
67
+ serial (str): Android device serialno
68
+ fx (int): From position X coordinate
69
+ fy (int): From position Y coordinate
70
+ tx (int): To position X coordinate
71
+ ty (int): To position Y coordinate
72
+ duration (float): duration
73
+ steps: 1 steps is about 5ms, if set, duration will be ignore
74
+ """
75
+ async with get_device(serial) as device:
76
+ await to_thread.run_sync(device.swipe, fx, fy, tx, ty, duration if duration > 0 else None, step if step > 0 else None)
77
+
78
+
79
+ @mcp.tool("swipe_points", tags={"action:gesture"})
80
+ async def swipe_points(serial: str, points: list[tuple[int, int]], duration: float = 0.5):
81
+ """Swipe through multiple points
82
+
83
+ Args:
84
+ serial (str): Android device serialno
85
+ points (list[tuple[int, int]]): List of (x, y) coordinates to swipe through
86
+ duration (float): Duration of swipe in seconds, default is 0.5
87
+ """
88
+ async with get_device(serial) as device:
89
+ await to_thread.run_sync(device.swipe_points, points, duration)
90
+
91
+
92
+ @mcp.tool("drag", tags={"action:gesture"})
93
+ async def drag(serial: str, sx: int, sy: int, ex: int, ey: int, duration: float = 0.5):
94
+ """Swipe from one point to another point.
95
+
96
+ Args:
97
+ serial (str): Android device serialno
98
+ sx (int): Start X coordinate
99
+ sy (int): Start Y coordinate
100
+ ex (int): End X coordinate
101
+ ey (int): End Y coordinate
102
+ duration (float): Duration of drag in seconds, default is 0.5
103
+ """
104
+ async with get_device(serial) as device:
105
+ await to_thread.run_sync(device.drag, sx, sy, ex, ey, duration)
106
+
107
+
108
+ @mcp.tool("press_key", tags={"action:key"})
109
+ async def press_key(serial: str, key: str):
110
+ """Press a key
111
+
112
+ Args:
113
+ serial (str): Android device serialno
114
+ key (str): Key to press.
115
+ Supported key name includes:
116
+ home, back, left, right, up, down, center, menu, search, enter,
117
+ delete(or del), recent(recent apps), volume_up, volume_down,
118
+ volume_mute, camera, power
119
+ """
120
+ async with get_device(serial) as device:
121
+ await to_thread.run_sync(device.press, key)
122
+
123
+
124
+ @mcp.tool("screen_on", tags={"action:screen"})
125
+ async def screen_on(serial: str):
126
+ """Turn screen on
127
+
128
+ Args:
129
+ serial (str): Android device serialno
130
+ """
131
+ async with get_device(serial) as device:
132
+ await to_thread.run_sync(device.screen_on)
133
+
134
+
135
+ @mcp.tool("screen_off", tags={"action:screen"})
136
+ async def screen_off(serial: str):
137
+ """Turn screen off
138
+
139
+ Args:
140
+ serial (str): Android device serialno
141
+ """
142
+ async with get_device(serial) as device:
143
+ await to_thread.run_sync(device.screen_off)