uiautomator2-mcp-server 0.1.3__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
@@ -11,23 +11,31 @@ Before performing operations on a device, you need to initialize it using the in
11
11
  All operations require a device serial number to identify the target device.
12
12
  """
13
13
 
14
+ from __future__ import annotations
15
+
16
+ import fnmatch
14
17
  import sys
15
18
  from contextlib import asynccontextmanager
16
19
  from functools import partial
17
20
  from textwrap import dedent
18
21
  from typing import Any
19
22
 
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
-
23
+ from anyio import create_task_group
25
24
  from fastmcp import FastMCP
26
25
  from fastmcp.server.auth import AccessToken, AuthProvider
27
26
  from pydantic import AnyHttpUrl
28
27
  from rich.console import Console
29
28
  from rich.markdown import Markdown
30
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
+
31
39
  __all__ = ["mcp", "make_mcp"]
32
40
 
33
41
 
@@ -35,21 +43,73 @@ __all__ = ["mcp", "make_mcp"]
35
43
  mcp: FastMCP
36
44
 
37
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
+
38
85
  @asynccontextmanager
39
- async def _lifespan(instance: FastMCP, token: str | None):
40
- content = Markdown(
41
- dedent(f"""
42
- ------
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:
43
101
 
44
- Server configured with **authentication token**. Connect using this token in the Authorization header:
102
+ `Authorization: Bearer {token}`
45
103
 
46
- `Authorization: Bearer {token}`
104
+ ------
105
+ """)
106
+ )
107
+ console.print(content)
47
108
 
48
- ------
49
- """)
50
- )
51
- Console(stderr=True).print(content)
52
- yield
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
53
113
 
54
114
 
55
115
  class _SimpleTokenAuthProvider(AuthProvider):
@@ -70,10 +130,43 @@ class _SimpleTokenAuthProvider(AuthProvider):
70
130
  return None
71
131
 
72
132
 
73
- def make_mcp(token: str | None = None) -> FastMCP:
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:
74
140
  global mcp
75
141
  params: dict[str, Any] = dict(name="uiautomator2", instructions=__doc__)
142
+ lifespan_kwargs: dict[str, Any] = {"show_tags": show_tags}
76
143
  if token:
77
- params.update(lifespan=partial(_lifespan, token=token), auth=_SimpleTokenAuthProvider(token=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))
78
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
+
79
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,4 +1,8 @@
1
1
  from .action import *
2
2
  from .app import *
3
+ from .clipboard import *
3
4
  from .device import *
5
+ from .element import *
6
+ from .input import *
4
7
  from .misc import *
8
+ from .scrcpy import *
u2mcp/tools/action.py CHANGED
@@ -13,14 +13,12 @@ __all__ = (
13
13
  "swipe_points",
14
14
  "drag",
15
15
  "press_key",
16
- "send_text",
17
- "clear_text",
18
16
  "screen_on",
19
17
  "screen_off",
20
18
  )
21
19
 
22
20
 
23
- @mcp.tool("click")
21
+ @mcp.tool("click", tags={"action:touch"})
24
22
  async def click(serial: str, x: int, y: int):
25
23
  """Click at specific coordinates
26
24
 
@@ -33,7 +31,7 @@ async def click(serial: str, x: int, y: int):
33
31
  await to_thread.run_sync(device.click, x, y)
34
32
 
35
33
 
36
- @mcp.tool("long_click")
34
+ @mcp.tool("long_click", tags={"action:touch"})
37
35
  async def long_click(serial: str, x: int, y: int, duration: float = 0.5):
38
36
  """Long click at specific coordinates
39
37
 
@@ -47,7 +45,7 @@ async def long_click(serial: str, x: int, y: int, duration: float = 0.5):
47
45
  await to_thread.run_sync(device.long_click, x, y, duration)
48
46
 
49
47
 
50
- @mcp.tool("double_click")
48
+ @mcp.tool("double_click", tags={"action:touch"})
51
49
  async def double_click(serial: str, x: int, y: int, duration: float = 0.1):
52
50
  """Double click at specific coordinates
53
51
 
@@ -61,7 +59,7 @@ async def double_click(serial: str, x: int, y: int, duration: float = 0.1):
61
59
  await to_thread.run_sync(device.double_click, x, y, duration)
62
60
 
63
61
 
64
- @mcp.tool("swipe")
62
+ @mcp.tool("swipe", tags={"action:gesture"})
65
63
  async def swipe(serial: str, fx: int, fy: int, tx: int, ty: int, duration: float = 0.0, step: int = 0):
66
64
  """Swipe from one point to another
67
65
 
@@ -78,7 +76,7 @@ async def swipe(serial: str, fx: int, fy: int, tx: int, ty: int, duration: float
78
76
  await to_thread.run_sync(device.swipe, fx, fy, tx, ty, duration if duration > 0 else None, step if step > 0 else None)
79
77
 
80
78
 
81
- @mcp.tool("swipe_points")
79
+ @mcp.tool("swipe_points", tags={"action:gesture"})
82
80
  async def swipe_points(serial: str, points: list[tuple[int, int]], duration: float = 0.5):
83
81
  """Swipe through multiple points
84
82
 
@@ -91,7 +89,7 @@ async def swipe_points(serial: str, points: list[tuple[int, int]], duration: flo
91
89
  await to_thread.run_sync(device.swipe_points, points, duration)
92
90
 
93
91
 
94
- @mcp.tool("drag")
92
+ @mcp.tool("drag", tags={"action:gesture"})
95
93
  async def drag(serial: str, sx: int, sy: int, ex: int, ey: int, duration: float = 0.5):
96
94
  """Swipe from one point to another point.
97
95
 
@@ -107,7 +105,7 @@ async def drag(serial: str, sx: int, sy: int, ex: int, ey: int, duration: float
107
105
  await to_thread.run_sync(device.drag, sx, sy, ex, ey, duration)
108
106
 
109
107
 
110
- @mcp.tool("press_key")
108
+ @mcp.tool("press_key", tags={"action:key"})
111
109
  async def press_key(serial: str, key: str):
112
110
  """Press a key
113
111
 
@@ -123,31 +121,7 @@ async def press_key(serial: str, key: str):
123
121
  await to_thread.run_sync(device.press, key)
124
122
 
125
123
 
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")
124
+ @mcp.tool("screen_on", tags={"action:screen"})
151
125
  async def screen_on(serial: str):
152
126
  """Turn screen on
153
127
 
@@ -158,7 +132,7 @@ async def screen_on(serial: str):
158
132
  await to_thread.run_sync(device.screen_on)
159
133
 
160
134
 
161
- @mcp.tool("screen_off")
135
+ @mcp.tool("screen_off", tags={"action:screen"})
162
136
  async def screen_off(serial: str):
163
137
  """Turn screen off
164
138
 
u2mcp/tools/app.py CHANGED
@@ -23,7 +23,7 @@ __all__ = (
23
23
  )
24
24
 
25
25
 
26
- @mcp.tool("app_install")
26
+ @mcp.tool("app_install", tags={"app:manage"})
27
27
  async def app_install(serial: str, data: str):
28
28
  """Install app
29
29
 
@@ -35,7 +35,7 @@ async def app_install(serial: str, data: str):
35
35
  await to_thread.run_sync(device.app_install, data)
36
36
 
37
37
 
38
- @mcp.tool("app_uninstall")
38
+ @mcp.tool("app_uninstall", tags={"app:manage"})
39
39
  async def app_uninstall(serial: str, package_name: str) -> bool:
40
40
  """Uninstall an app
41
41
 
@@ -50,7 +50,7 @@ async def app_uninstall(serial: str, package_name: str) -> bool:
50
50
  return await to_thread.run_sync(device.app_uninstall, package_name)
51
51
 
52
52
 
53
- @mcp.tool("app_uninstall_all")
53
+ @mcp.tool("app_uninstall_all", tags={"app:manage"})
54
54
  async def app_uninstall_all(serial: str, excludes: list[str] | None = None) -> list[str]:
55
55
  """Uninstall all apps
56
56
 
@@ -65,7 +65,7 @@ async def app_uninstall_all(serial: str, excludes: list[str] | None = None) -> l
65
65
  return await to_thread.run_sync(device.app_uninstall_all, excludes or [])
66
66
 
67
67
 
68
- @mcp.tool("app_start")
68
+ @mcp.tool("app_start", tags={"app:lifecycle"})
69
69
  async def app_start(
70
70
  serial: str,
71
71
  package_name: str,
@@ -86,7 +86,7 @@ async def app_start(
86
86
  await to_thread.run_sync(device.app_start, package_name, activity, wait, stop)
87
87
 
88
88
 
89
- @mcp.tool("app_wait")
89
+ @mcp.tool("app_wait", tags={"app:lifecycle"})
90
90
  async def app_wait(serial: str, package_name: str, timeout: float = 20.0, front=False):
91
91
  """Wait until app launched
92
92
 
@@ -101,7 +101,7 @@ async def app_wait(serial: str, package_name: str, timeout: float = 20.0, front=
101
101
  raise RuntimeError(f"Failed to wait App {package_name} to launch")
102
102
 
103
103
 
104
- @mcp.tool("app_stop")
104
+ @mcp.tool("app_stop", tags={"app:lifecycle"})
105
105
  async def app_stop(serial: str, package_name: str):
106
106
  """Stop one application
107
107
 
@@ -113,7 +113,7 @@ async def app_stop(serial: str, package_name: str):
113
113
  await to_thread.run_sync(device.app_stop, package_name)
114
114
 
115
115
 
116
- @mcp.tool("app_stop_all")
116
+ @mcp.tool("app_stop_all", tags={"app:lifecycle"})
117
117
  async def app_stop_all(serial: str, excludes: list[str] | None = None) -> list[str]:
118
118
  """Stop all third party applications
119
119
 
@@ -127,7 +127,7 @@ async def app_stop_all(serial: str, excludes: list[str] | None = None) -> list[s
127
127
  return await to_thread.run_sync(device.app_stop_all, excludes or [])
128
128
 
129
129
 
130
- @mcp.tool("app_clear")
130
+ @mcp.tool("app_clear", tags={"app:config"})
131
131
  async def app_clear(serial: str, package_name: str):
132
132
  """Stop and clear app data: pm clear
133
133
 
@@ -142,7 +142,7 @@ async def app_clear(serial: str, package_name: str):
142
142
  await to_thread.run_sync(device.app_clear, package_name)
143
143
 
144
144
 
145
- @mcp.tool("app_info")
145
+ @mcp.tool("app_info", tags={"app:info"})
146
146
  async def app_info(serial: str, package_name: str) -> dict[str, Any]:
147
147
  """
148
148
  Get app info
@@ -161,7 +161,7 @@ async def app_info(serial: str, package_name: str) -> dict[str, Any]:
161
161
  return await to_thread.run_sync(device.app_info, package_name)
162
162
 
163
163
 
164
- @mcp.tool("app_current")
164
+ @mcp.tool("app_current", tags={"app:info"})
165
165
  async def app_current(serial: str) -> dict[str, Any]:
166
166
  """
167
167
  Get current app info
@@ -176,7 +176,7 @@ async def app_current(serial: str) -> dict[str, Any]:
176
176
  return await to_thread.run_sync(device.app_current)
177
177
 
178
178
 
179
- @mcp.tool("app_list")
179
+ @mcp.tool("app_list", tags={"app:info"})
180
180
  async def app_list(serial: str, filter: str = "") -> list[str]:
181
181
  """
182
182
  List installed app package names
@@ -192,7 +192,7 @@ async def app_list(serial: str, filter: str = "") -> list[str]:
192
192
  return await to_thread.run_sync(device.app_list, filter.strip())
193
193
 
194
194
 
195
- @mcp.tool("app_list_running")
195
+ @mcp.tool("app_list_running", tags={"app:info"})
196
196
  async def app_list_running(serial: str) -> list[str]:
197
197
  """
198
198
  List running apps
@@ -207,7 +207,7 @@ async def app_list_running(serial: str) -> list[str]:
207
207
  return await to_thread.run_sync(device.app_list_running)
208
208
 
209
209
 
210
- @mcp.tool("app_auto_grant_permissions")
210
+ @mcp.tool("app_auto_grant_permissions", tags={"app:config"})
211
211
  async def app_auto_grant_permissions(serial: str, package_name: str):
212
212
  """auto grant permissions
213
213
 
@@ -0,0 +1,35 @@
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__ = ("read_clipboard", "write_clipboard")
9
+
10
+
11
+ @mcp.tool("read_clipboard", tags={"clipboard:read"})
12
+ async def read_clipboard(serial: str) -> str | None:
13
+ """Read clipboard from device
14
+
15
+ Args:
16
+ serial (str): Android device serialno
17
+
18
+ Returns:
19
+ str: The actual text in the clip.
20
+ None: If there is no text in the clip.
21
+ """
22
+ async with get_device(serial) as device:
23
+ return await to_thread.run_sync(lambda: device.clipboard)
24
+
25
+
26
+ @mcp.tool("write_clipboard", tags={"clipboard:write"})
27
+ async def write_clipboard(serial: str, text: str):
28
+ """Write clipboard to device
29
+
30
+ Args:
31
+ serial (str): Android device serialno
32
+ text: The actual text in the clip.
33
+ """
34
+ async with get_device(serial) as device:
35
+ await to_thread.run_sync(device.set_clipboard, text)