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/.gitignore CHANGED
@@ -1 +1 @@
1
- _version.py
1
+ version.py
u2mcp/__init__.py CHANGED
@@ -1,2 +1 @@
1
- from . import _version as version
2
- from ._version import __commit_id__, __version__, __version_tuple__
1
+ from .version import __commit_id__, __version__, __version_tuple__
u2mcp/__main__.py CHANGED
@@ -1,82 +1,193 @@
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
-
11
- def run(
12
- transport: Annotated[
13
- Literal["http", "stdio"], typer.Argument(help="Run mcp server on streamable-http http or stdio transport")
14
- ] = "stdio",
15
- host: Annotated[
16
- str, typer.Option("--host", "-H", show_default=False, help="Host address of streamable-http transport")
17
- ] = "127.0.0.1",
18
- port: Annotated[
19
- int, typer.Option("--port", "-p", show_default=False, help="Port number of streamable-http transport")
20
- ] = 8000,
21
- json_response: Annotated[bool, typer.Option("--json-response", "-j", help="Whether to use JSON response format")] = True,
22
- log_level: Annotated[
23
- Literal["debug", "info", "warning", "error", "critical"], typer.Option("--log-level", "-l", help="Log level")
24
- ] = "info",
25
- no_token: Annotated[
26
- bool,
27
- typer.Option(
28
- "--no-token",
29
- help="Disable authentication bearer token verification of streamable-http transport. If not set, a token will be generated randomly.",
30
- ),
31
- ] = False,
32
- token: Annotated[
33
- str | None,
34
- typer.Option("--token", "-t", help="Explicit set token of streamable-http authentication"),
35
- ] = None,
36
- ):
37
- """Run uiautomator2 mcp server"""
38
- logging.basicConfig(
39
- level=log_level.upper(),
40
- format="[%(asctime)s] %(levelname)s %(name)s - %(message)s",
41
- handlers=[logging.StreamHandler()],
42
- force=True,
43
- )
44
-
45
- logging.getLogger("mcp.server").setLevel(logging.WARNING)
46
- logging.getLogger("sse_starlette").setLevel(logging.WARNING)
47
- logging.getLogger("docket").setLevel(logging.WARNING)
48
- logging.getLogger("fakeredis").setLevel(logging.WARNING)
49
-
50
- from . import tools as _
51
- from .mcp import mcp, update_params
52
-
53
- transport_kwargs: dict[str, Any] = {"json_response": json_response}
54
-
55
- update_params(transport=transport)
56
-
57
- if transport == "http":
58
- if token:
59
- token = token.strip()
60
- if not re.match(r"^[a-zA-Z0-9\-_.~!$&'()*+,;=:@]{8,64}$", token):
61
- raise typer.BadParameter("Token must be 8-64 characters long and can only contain URL-safe characters")
62
- elif not no_token:
63
- token = secrets.token_urlsafe()
64
- if token:
65
- update_params(token=token, host=host, port=port)
66
-
67
- if host:
68
- transport_kwargs["host"] = host
69
- if port:
70
- transport_kwargs["port"] = port
71
-
72
- mcp.run(transport="streamable-http", **transport_kwargs, log_level=log_level)
73
- else:
74
- mcp.run(log_level=log_level)
75
-
76
-
77
- def main():
78
- typer.run(run)
79
-
80
-
81
- if __name__ == "__main__":
82
- main()
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ import secrets
6
+ import sys
7
+ from typing import Annotated, Any, Literal
8
+
9
+ import anyio
10
+ from cyclopts import App, Group, Parameter
11
+ from cyclopts.exceptions import ValidationError
12
+ from rich.console import Console
13
+
14
+ from .health import check_adb
15
+ from .helpers import print_tags as print_tags_from_mcp
16
+ from .helpers import print_tool_help
17
+ from .mcp import make_mcp
18
+ from .version import __version__
19
+
20
+
21
+ def _version_callback() -> str:
22
+ """Return version information."""
23
+ return f"{__name__} {__version__} (Python {sys.version})"
24
+
25
+
26
+ # Organize commands into groups
27
+ server_group = Group("Server Commands")
28
+ info_group = Group("Information Commands")
29
+
30
+ # Create CLI app with cyclopts
31
+ app = App(name="u2mcp", help="uiautomator2-mcp-server - MCP server for Android device automation", version=_version_callback())
32
+
33
+
34
+ def _setup_logging(log_level: Literal["debug", "info", "warning", "error", "critical"]) -> None:
35
+ """Configure logging for the MCP server."""
36
+ logging.basicConfig(
37
+ level=log_level.upper(),
38
+ format="[%(asctime)s] %(levelname)8s %(name)s - %(message)s",
39
+ handlers=[logging.StreamHandler()],
40
+ force=True,
41
+ )
42
+ logging.getLogger("mcp.server").setLevel(logging.WARNING)
43
+ logging.getLogger("sse_starlette").setLevel(logging.WARNING)
44
+ logging.getLogger("docket").setLevel(logging.WARNING)
45
+ logging.getLogger("fakeredis").setLevel(logging.WARNING)
46
+
47
+
48
+ def _check_adb(console: Console, check: bool) -> None:
49
+ """Check ADB availability if enabled."""
50
+ if check and not check_adb(console):
51
+ console.print("[yellow]Proceeding anyway. Use --no-check-adb to bypass this check.[/yellow]")
52
+
53
+
54
+ def _validate_token(token: str) -> str:
55
+ """Validate token format."""
56
+ token = token.strip()
57
+ if not re.match(r"^[a-zA-Z0-9\-_.~!$&'()*+,;=:@]{8,64}$", token):
58
+ raise ValidationError("Token must be 8-64 characters long and can only contain URL-safe characters")
59
+ return token
60
+
61
+
62
+ @app.command(group=server_group)
63
+ def stdio(
64
+ *,
65
+ check_adb: bool = True,
66
+ log_level: Annotated[
67
+ Literal["debug", "info", "warning", "error", "critical"], Parameter(name=["--log-level", "-l"])
68
+ ] = "info",
69
+ include_tags: Annotated[str | None, Parameter(name=["--include-tags", "-i"])] = None,
70
+ exclude_tags: Annotated[str | None, Parameter(name=["--exclude-tags", "-e"])] = None,
71
+ print_tags: bool = True,
72
+ fix_empty_responses: bool = False,
73
+ show_fastmcp_banner: bool = False,
74
+ ) -> None:
75
+ """Run the MCP server with stdio transport.
76
+
77
+ Args:
78
+ check_adb: Check ADB availability at startup.
79
+ log_level: Log level.
80
+ include_tags: Only expose tools with these tags (comma-separated, supports * and ? wildcards, e.g., device:*,*:shell).
81
+ exclude_tags: Exclude tools with these tags (comma-separated, supports * and ? wildcards, e.g., screen:*,*:mirror).
82
+ print_tags: Show enabled tags and tools at startup.
83
+ fix_empty_responses: Convert null tool responses to empty string compatibility.
84
+ show_fastmcp_banner: Show FastMCP banner on startup.
85
+ """
86
+ _setup_logging(log_level)
87
+ _check_adb(Console(stderr=True), check_adb)
88
+ mcp = make_mcp(
89
+ show_tags=print_tags, include_tags=include_tags, exclude_tags=exclude_tags, fix_empty_responses=fix_empty_responses
90
+ )
91
+ mcp.run("stdio", show_fastmcp_banner, log_level=log_level)
92
+
93
+
94
+ @app.command(group=server_group)
95
+ def http(
96
+ *,
97
+ host: Annotated[str | None, Parameter(name=["--host", "-H"])] = None,
98
+ port: Annotated[int | None, Parameter(name=["--port", "-p"])] = None,
99
+ token: Annotated[str | None, Parameter(name=["--token", "-t"])] = None,
100
+ no_token: Annotated[bool, Parameter(name=["--no-token", "-n"])] = False,
101
+ json_response: bool = True,
102
+ check_adb: bool = True,
103
+ log_level: Annotated[
104
+ Literal["debug", "info", "warning", "error", "critical"], Parameter(name=["--log-level", "-l"])
105
+ ] = "info",
106
+ include_tags: Annotated[str | None, Parameter(name=["--include-tags", "-i"])] = None,
107
+ exclude_tags: Annotated[str | None, Parameter(name=["--exclude-tags", "-e"])] = None,
108
+ print_tags: bool = True,
109
+ fix_empty_responses: bool = False,
110
+ show_fastmcp_banner: bool = False,
111
+ ) -> None:
112
+ """Run the MCP server with HTTP (streamable-http) transport.
113
+
114
+ Args:
115
+ host: Host address to bind to.
116
+ port: Port number to bind to.
117
+ token: Explicit set authentication token.
118
+ no_token: Disable authentication bearer token verification. If not set, a token will be generated randomly.
119
+ json_response: Use JSON response format.
120
+ check_adb: Check ADB availability at startup.
121
+ log_level: Log level.
122
+ include_tags: Only expose tools with these tags (comma-separated, supports * and ? wildcards, e.g., device:*,*:shell).
123
+ exclude_tags: Exclude tools with these tags (comma-separated, supports * and ? wildcards, e.g., screen:*,*:mirror).
124
+ print_tags: Show enabled tags and tools at startup.
125
+ fix_empty_responses: Convert null tool responses to empty string compatibility.
126
+ show_fastmcp_banner: Show FastMCP banner on startup.
127
+ """
128
+ _setup_logging(log_level)
129
+ _check_adb(Console(stderr=True), check_adb)
130
+
131
+ if token:
132
+ token = _validate_token(token)
133
+ elif not no_token:
134
+ token = secrets.token_urlsafe()
135
+
136
+ transport_kwargs: dict[str, Any] = {"json_response": json_response}
137
+ if host is not None:
138
+ transport_kwargs["host"] = host
139
+ if port is not None:
140
+ transport_kwargs["port"] = port
141
+
142
+ mcp = make_mcp(
143
+ token,
144
+ show_tags=print_tags,
145
+ include_tags=include_tags,
146
+ exclude_tags=exclude_tags,
147
+ fix_empty_responses=fix_empty_responses,
148
+ )
149
+ mcp.run("streamable-http", show_fastmcp_banner, **transport_kwargs)
150
+
151
+
152
+ @app.command(group=info_group)
153
+ def tools() -> None:
154
+ """List all available MCP tools."""
155
+ console = Console()
156
+ mcp = make_mcp()
157
+ anyio.run(lambda: print_tool_help(mcp, console, None))
158
+
159
+
160
+ @app.command(group=info_group)
161
+ def info(
162
+ tool_name: str,
163
+ ) -> None:
164
+ """Show detailed information about a specific tool.
165
+
166
+ Examples:
167
+ u2mcp info screenshot # Show screenshot tool details
168
+ u2mcp info device:* # Show all device tools
169
+ u2mcp info "*screenshot*" # Show tools with 'screenshot' in name
170
+
171
+ Args:
172
+ tool_name: Tool name or pattern (supports * and ? wildcards).
173
+ """
174
+ console = Console()
175
+ mcp = make_mcp()
176
+ anyio.run(lambda: print_tool_help(mcp, console, tool_name))
177
+
178
+
179
+ @app.command(group=info_group)
180
+ def tags() -> None:
181
+ """List all available tool tags."""
182
+ console = Console()
183
+ mcp = make_mcp()
184
+ anyio.run(lambda: print_tags_from_mcp(mcp, console, filtered=False))
185
+
186
+
187
+ def main() -> None:
188
+ """Entry point for the CLI."""
189
+ app()
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
u2mcp/background.py ADDED
@@ -0,0 +1,19 @@
1
+ """Global background task management for long-running tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from anyio.abc import TaskGroup
6
+
7
+ # Global task group for background tasks (set by lifespan)
8
+ _task_group: TaskGroup
9
+
10
+
11
+ def get_background_task_group() -> TaskGroup:
12
+ """Get the global background task group."""
13
+ return _task_group
14
+
15
+
16
+ def set_background_task_group(tg: TaskGroup) -> None:
17
+ """Set the global monitor task group."""
18
+ global _task_group
19
+ _task_group = tg
u2mcp/health.py ADDED
@@ -0,0 +1,67 @@
1
+ """Simple ADB availability check for uiautomator2 MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from rich.console import Console
8
+
9
+ __all__ = ["check_adb"]
10
+
11
+
12
+ def check_adb(console: Console | None = None) -> bool:
13
+ """Check if ADB is available.
14
+
15
+ Returns:
16
+ True if ADB is working, False otherwise.
17
+ """
18
+ if console is None:
19
+ console = Console(stderr=True)
20
+
21
+ try:
22
+ import adbutils
23
+
24
+ # Try to connect to ADB server
25
+ adb_server_version = adbutils.adb.server_version()
26
+ console.print(f"[green]✓ ADB server version: {adb_server_version}[/green]")
27
+ adb_device_list = adbutils.adb.device_list()
28
+ console.print(f"[green]✓ ADB device list: {adb_device_list}[/green]")
29
+ return True
30
+
31
+ except Exception as e:
32
+ console.print(f"[yellow]⚠ Cannot connect to ADB: {e}[/yellow]")
33
+ console.print()
34
+
35
+ _print_adb_fix_help(console)
36
+ return False
37
+
38
+
39
+ def _print_adb_fix_help(console: Console) -> None:
40
+ """Print helpful messages for fixing ADB issues."""
41
+ console.print("[cyan]Possible fixes:[/cyan]")
42
+
43
+ # Install ADB
44
+ console.print(" 1. Install ADB:")
45
+ if sys.platform == "darwin": # pragma: no cover
46
+ console.print(" [white]brew install android-platform-tools[/white]")
47
+ elif sys.platform == "linux": # pragma: no cover
48
+ console.print(" [white]sudo apt install adb[/white] # Debian/Ubuntu")
49
+ console.print(" [white]sudo yum install android-tools[/white] # Fedora/RHEL")
50
+ elif sys.platform == "win32": # pragma: no cover
51
+ console.print(" Download: https://developer.android.com/tools/releases/platform-tools")
52
+ console.print(" Or use: [white]winget install Google.PlatformTools[/white]")
53
+
54
+ # Start ADB server
55
+ console.print(" 2. Start ADB server:")
56
+ console.print(" [white]adb start-server[/white]")
57
+
58
+ # Custom ADB path
59
+ console.print(" 3. Set custom ADB path (if needed):")
60
+ if sys.platform == "win32": # pragma: no cover
61
+ console.print(" CMD: [white]set ADBUTILS_ADB_PATH=C:\\path\\to\\adb.exe[/white]")
62
+ console.print(" PowerShell: [white]$env:ADBUTILS_ADB_PATH='C:\\path\\to\\adb.exe'[/white]")
63
+ else: # pragma: no cover
64
+ console.print(" [white]export ADBUTILS_ADB_PATH=/path/to/adb[/white]")
65
+
66
+ console.print()
67
+ console.print("[yellow]Proceeding anyway. Use --skip-adb-check to bypass this check.[/yellow]")
u2mcp/helpers.py ADDED
@@ -0,0 +1,222 @@
1
+ """Helper functions for u2mcp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from docstring_parser import parse
8
+ from rich.console import Console
9
+ from rich.markdown import Markdown
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ if TYPE_CHECKING:
14
+ from .mcp import FastMCP
15
+
16
+
17
+ __all__ = ["print_tags", "print_tool_help"]
18
+
19
+
20
+ async def print_tags(instance: FastMCP, console: Console, *, filtered: bool = True):
21
+ """Print tags from an MCP instance.
22
+
23
+ Args:
24
+ instance: The MCP instance to get tools from
25
+ console: The Rich console to print to
26
+ filtered: If True, only show tags that match include/exclude filters.
27
+ If False, show all available tags. Defaults to True.
28
+ """
29
+ tags_map: dict[str, list[str]] = {}
30
+ include_tags = getattr(instance, "include_tags", None) if filtered else None
31
+ exclude_tags = getattr(instance, "exclude_tags", None) if filtered else None
32
+
33
+ for tool in (await instance.get_tools()).values():
34
+ # Skip tools with no tags
35
+ if not tool.tags:
36
+ continue
37
+
38
+ # Apply include filter
39
+ if include_tags is not None and not any(tag in include_tags for tag in tool.tags):
40
+ continue
41
+
42
+ # Apply exclude filter
43
+ if exclude_tags is not None and any(tag in exclude_tags for tag in tool.tags):
44
+ continue
45
+
46
+ for tag in tool.tags:
47
+ # Only include tags that pass the filters
48
+ if include_tags is not None and tag not in include_tags:
49
+ continue
50
+ if exclude_tags is not None and tag in exclude_tags:
51
+ continue
52
+
53
+ if tag not in tags_map:
54
+ tags_map[tag] = []
55
+ tags_map[tag].append(tool.name)
56
+
57
+ # Sort tags by category
58
+ sorted_tags = sorted(tags_map.keys())
59
+
60
+ # Group by category
61
+ categories: dict[str, list[tuple[str, list[str]]]] = {}
62
+ for tag in sorted_tags:
63
+ if ":" in tag:
64
+ category, subcategory = tag.split(":", 1)
65
+ if category not in categories:
66
+ categories[category] = []
67
+ categories[category].append((subcategory, tags_map[tag]))
68
+ else:
69
+ # Tags without category (if any)
70
+ if "" not in categories:
71
+ categories[""] = []
72
+ categories[""].append((tag, tags_map[tag]))
73
+
74
+ # Print table for each category
75
+ for category in sorted(categories.keys()):
76
+ if category == "":
77
+ console.print("\n[bold]Other Tags:[/bold]")
78
+ else:
79
+ console.print(f"\n[bold]{category.title()} Tags:[/bold]")
80
+
81
+ table = Table(show_header=True, header_style="bold magenta")
82
+ table.add_column("Tag", style="cyan", width=20)
83
+ table.add_column("Tools", style="green")
84
+
85
+ for subcategory, tools in sorted(categories[category]):
86
+ tag_name = f"{category}:{subcategory}" if category else subcategory
87
+ tools_str = ", ".join(sorted(tools))
88
+ table.add_row(tag_name, tools_str)
89
+
90
+ console.print(table)
91
+
92
+ console.print(f"\n[bold]Total: {len(tags_map)} tags, {sum(len(v) for v in tags_map.values())} tool-tag assignments[/bold]")
93
+
94
+ # Show filter info if filters are active
95
+ include_tags = getattr(instance, "include_tags", None)
96
+ exclude_tags = getattr(instance, "exclude_tags", None)
97
+ if include_tags is not None or exclude_tags is not None:
98
+ console.print("\n[dim]Active filters:[/dim]")
99
+ if include_tags is not None:
100
+ console.print(f" [cyan]include:[/cyan] {', '.join(sorted(include_tags))}")
101
+ if exclude_tags is not None:
102
+ console.print(f" [cyan]exclude:[/cyan] {', '.join(sorted(exclude_tags))}")
103
+ console.print("\n[dim]Use --include-tags and --exclude-tags when running the server to filter available tools.[/dim]")
104
+
105
+
106
+ async def print_tool_help(instance: FastMCP, console: Console, tool_name: str | None = None):
107
+ """Print help information for MCP tools.
108
+
109
+ Args:
110
+ instance: The MCP instance to get tools from
111
+ console: The Rich console to print to
112
+ tool_name: If specified, show help for tools matching the pattern.
113
+ Can be a tool name pattern or a tag pattern (e.g., device:*).
114
+ If None, list all available tools. Supports * and ? wildcards.
115
+ """
116
+ import fnmatch
117
+
118
+ tools = await instance.get_tools()
119
+
120
+ if tool_name:
121
+ # Filter tools by name pattern OR tag pattern
122
+ matched_tools: dict[str, Any] = {}
123
+ for name, tool in tools.items():
124
+ # Check if tool name matches pattern
125
+ if fnmatch.fnmatch(name, tool_name):
126
+ matched_tools[name] = tool
127
+ continue
128
+
129
+ # Check if any tag matches pattern
130
+ if tool.tags:
131
+ for tag in tool.tags:
132
+ if fnmatch.fnmatch(tag, tool_name):
133
+ matched_tools[name] = tool
134
+ break
135
+
136
+ if not matched_tools:
137
+ console.print(f"[red]No tools found matching pattern: {tool_name}[/red]")
138
+ console.print("[dim]Tip: Use 'u2mcp tools' (no arguments) to list all tools.[/dim]")
139
+ console.print("[dim]Tip: Use 'u2mcp tags' to list all available tags.[/dim]")
140
+ return
141
+
142
+ for name, tool in sorted(matched_tools.items()):
143
+ _print_single_tool_help(console, name, tool)
144
+ else:
145
+ # List all tools
146
+ table = Table(show_header=True, header_style="bold magenta")
147
+ table.add_column("Tool Name", style="cyan", width=25)
148
+ table.add_column("Description", style="white")
149
+ table.add_column("Tags", style="green", width=30)
150
+
151
+ for name, tool in sorted(tools.items()):
152
+ tags_str = ", ".join(sorted(tool.tags or [])) if tool.tags else ""
153
+ # Extract short description only, skip Args/Returns sections
154
+ description = tool.description or ""
155
+ parsed = parse(description)
156
+ # Use short description, fall back to full description truncated
157
+ desc = parsed.short_description if parsed.short_description else description
158
+ # Truncate if too long
159
+ desc = desc[:57] + "..." if len(desc) > 60 else desc
160
+ table.add_row(name, desc, tags_str)
161
+
162
+ console.print("\n[bold cyan]Available Tools:[/bold cyan]")
163
+ console.print(table)
164
+ console.print(f"\n[dim]Total: {len(tools)} tools[/dim]")
165
+ console.print("\n[dim]Use 'u2mcp info <tool_name>' for detailed information about a specific tool.[/dim]")
166
+ console.print("[dim]Supports wildcards: 'u2mcp info device:*' (by tag) or 'u2mcp info *screenshot*' (by name)[/dim]")
167
+
168
+
169
+ def _print_single_tool_help(console: Console, name: str, tool: Any) -> None:
170
+ """Print detailed help for a single tool.
171
+
172
+ Parses doc-strings and formats Args/Returns with markdown.
173
+ """
174
+ # Build markdown content
175
+ md_lines = []
176
+
177
+ # Title and tags (as plain text, not markdown)
178
+ tags_str = f"**Tags:** {', '.join(sorted(tool.tags))}" if tool.tags else ""
179
+
180
+ # Parse the docstring
181
+ description = tool.description or ""
182
+ parsed = parse(description)
183
+
184
+ # Short description
185
+ if parsed.short_description:
186
+ md_lines.append(parsed.short_description)
187
+
188
+ # Long description
189
+ if parsed.long_description:
190
+ md_lines.append(parsed.long_description)
191
+
192
+ # Args section
193
+ if parsed.params:
194
+ md_lines.append("\n**Args:**")
195
+ for p in parsed.params:
196
+ type_suffix = f" ({p.type_name})" if p.type_name else ""
197
+ default_suffix = f"(default: `{p.default}`)" if p.default else ""
198
+ md_lines.append(f"- **{p.arg_name}**{type_suffix}: {p.description}{default_suffix}")
199
+
200
+ # Returns section
201
+ if parsed.returns:
202
+ md_lines.append("\n**Returns:**")
203
+ r = parsed.returns
204
+ if r.type_name:
205
+ md_lines.append(f"**{r.type_name}**")
206
+ if r.description:
207
+ md_lines.append(r.description)
208
+
209
+ # Combine tags and description
210
+ full_md = tags_str + "\n\n" + "\n".join(md_lines) if tags_str else "\n".join(md_lines)
211
+
212
+ # Display in a panel with markdown rendering
213
+ console.print(
214
+ Panel(
215
+ Markdown(full_md),
216
+ title=f"[bold cyan]{name}[/bold cyan]",
217
+ title_align="left",
218
+ border_style="cyan",
219
+ padding=(1, 1, 1, 1),
220
+ )
221
+ )
222
+ console.print() # Blank line after each tool