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 +1 -1
- u2mcp/__init__.py +1 -2
- u2mcp/__main__.py +193 -82
- u2mcp/background.py +19 -0
- u2mcp/health.py +67 -0
- u2mcp/helpers.py +222 -0
- u2mcp/mcp.py +172 -61
- u2mcp/middlewares.py +40 -0
- u2mcp/tools/__init__.py +8 -3
- u2mcp/tools/action.py +143 -169
- u2mcp/tools/app.py +232 -231
- u2mcp/tools/clipboard.py +35 -0
- u2mcp/tools/device.py +307 -293
- u2mcp/tools/element.py +267 -0
- u2mcp/tools/input.py +47 -0
- u2mcp/tools/misc.py +17 -0
- u2mcp/tools/scrcpy.py +142 -0
- u2mcp/{_version.py → version.py} +34 -34
- uiautomator2_mcp_server-0.2.0.dist-info/METADATA +738 -0
- uiautomator2_mcp_server-0.2.0.dist-info/RECORD +25 -0
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/WHEEL +1 -1
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/entry_points.txt +1 -0
- uiautomator2_mcp_server-0.2.0.dist-info/licenses/LICENSE +190 -0
- uiautomator2_mcp_server-0.1.2.dist-info/METADATA +0 -113
- uiautomator2_mcp_server-0.1.2.dist-info/RECORD +0 -16
- uiautomator2_mcp_server-0.1.2.dist-info/licenses/LICENSE +0 -620
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/top_level.txt +0 -0
u2mcp/.gitignore
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
version.py
|
u2mcp/__init__.py
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
from . import
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
logging.getLogger("
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|