Skip to content

Commit 6650e72

Browse files
authored
Update for CLI (#636)
(1) Custom Tool fix (2) Include more tips and helps with CLI, including a CLI_EXAMPLE.md with @JohanHoltby's feedback!
1 parent 17eb171 commit 6650e72

10 files changed

Lines changed: 505 additions & 3 deletions

File tree

Server/src/cli/CLI_USAGE_GUIDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,18 @@ unity-mcp editor tests
533533
unity-mcp editor tests --mode PlayMode
534534
```
535535

536+
### Custom Tools
537+
538+
```bash
539+
# List custom tools / default tools for the active Unity project
540+
unity-mcp tool list
541+
unity-mcp custom_tool list
542+
543+
# Execute a custom tool by name
544+
unity-mcp editor custom-tool "MyBuildTool"
545+
unity-mcp editor custom-tool "Deploy" --params '{"target": "Android"}'
546+
```
547+
536548
### Prefab Commands
537549

538550
```bash

Server/src/cli/commands/editor.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
from cli.utils.config import get_config
88
from cli.utils.output import format_output, print_error, print_success, print_info
9-
from cli.utils.connection import run_command, UnityConnectionError
9+
from cli.utils.connection import run_command, run_list_custom_tools, UnityConnectionError
10+
from cli.utils.suggestions import suggest_matches, format_suggestions
1011

1112

1213
@click.group()
@@ -472,6 +473,8 @@ def custom_tool(tool_name: str, params: str):
472473
params_dict = json.loads(params)
473474
except json.JSONDecodeError as e:
474475
print_error(f"Invalid JSON for params: {e}")
476+
print_info("Example: --params '{\"key\":\"value\"}'")
477+
print_info("Tip: wrap JSON in single quotes to avoid shell escaping issues.")
475478
sys.exit(1)
476479

477480
try:
@@ -482,6 +485,26 @@ def custom_tool(tool_name: str, params: str):
482485
click.echo(format_output(result, config.format))
483486
if result.get("success"):
484487
print_success(f"Executed custom tool: {tool_name}")
488+
else:
489+
message = (result.get("message") or result.get("error") or "").lower()
490+
if "not found" in message and "tool" in message:
491+
try:
492+
tools_result = run_list_custom_tools(config)
493+
tools = tools_result.get("tools")
494+
if tools is None:
495+
data = tools_result.get("data", {})
496+
tools = data.get("tools") if isinstance(data, dict) else None
497+
names = [
498+
t.get("name") for t in tools if isinstance(t, dict) and t.get("name")
499+
] if isinstance(tools, list) else []
500+
matches = suggest_matches(tool_name, names)
501+
suggestion = format_suggestions(matches)
502+
if suggestion:
503+
print_info(suggestion)
504+
print_info(
505+
f'Example: unity-mcp editor custom-tool "{matches[0]}"')
506+
except UnityConnectionError:
507+
pass
485508
except UnityConnectionError as e:
486509
print_error(str(e))
487510
sys.exit(1)

Server/src/cli/commands/tool.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Tool CLI commands for listing custom tools."""
2+
3+
import sys
4+
import click
5+
6+
from cli.utils.config import get_config
7+
from cli.utils.output import format_output, print_error
8+
from cli.utils.connection import run_list_custom_tools, UnityConnectionError
9+
10+
11+
def _list_custom_tools() -> None:
12+
config = get_config()
13+
try:
14+
result = run_list_custom_tools(config)
15+
if config.format != "text":
16+
click.echo(format_output(result, config.format))
17+
return
18+
19+
if not isinstance(result, dict) or not result.get("success", True):
20+
click.echo(format_output(result, config.format))
21+
return
22+
23+
tools = result.get("tools")
24+
if tools is None:
25+
data = result.get("data", {})
26+
tools = data.get("tools") if isinstance(data, dict) else None
27+
if not isinstance(tools, list):
28+
click.echo(format_output(result, config.format))
29+
return
30+
31+
click.echo(f"Custom tools ({len(tools)}):")
32+
for i, tool in enumerate(tools):
33+
name = tool.get("name") if isinstance(tool, dict) else str(tool)
34+
click.echo(f" [{i}] {name}")
35+
except UnityConnectionError as e:
36+
print_error(str(e))
37+
sys.exit(1)
38+
39+
40+
@click.group("tool")
41+
def tool():
42+
"""Tool management - list custom tools for the active Unity project."""
43+
pass
44+
45+
46+
@tool.command("list")
47+
def list_tools():
48+
"""List custom tools registered for the active Unity project."""
49+
_list_custom_tools()
50+
51+
52+
@click.group("custom_tool")
53+
def custom_tool():
54+
"""Alias for tool management (custom tools)."""
55+
pass
56+
57+
58+
@custom_tool.command("list")
59+
def list_custom_tools():
60+
"""List custom tools registered for the active Unity project."""
61+
_list_custom_tools()

Server/src/cli/main.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from cli import __version__
1010
from cli.utils.config import CLIConfig, set_config, get_config
11+
from cli.utils.suggestions import suggest_matches, format_suggestions
1112
from cli.utils.output import format_output, print_error, print_success, print_info
1213
from cli.utils.connection import (
1314
run_command,
@@ -28,6 +29,35 @@ def __init__(self):
2829
pass_context = click.make_pass_decorator(Context, ensure=True)
2930

3031

32+
_ORIGINAL_RESOLVE_COMMAND = click.Group.resolve_command
33+
34+
35+
def _resolve_command_with_suggestions(self: click.Group, ctx: click.Context, args: list[str]):
36+
try:
37+
return _ORIGINAL_RESOLVE_COMMAND(self, ctx, args)
38+
except click.exceptions.NoSuchCommand as e:
39+
if not args or args[0].startswith("-"):
40+
raise
41+
matches = suggest_matches(args[0], self.list_commands(ctx))
42+
suggestion = format_suggestions(matches)
43+
if suggestion:
44+
message = f"{e}\n{suggestion}"
45+
raise click.exceptions.UsageError(message, ctx=ctx)
46+
raise
47+
except click.exceptions.UsageError as e:
48+
if args and not args[0].startswith("-") and "No such command" in str(e):
49+
matches = suggest_matches(args[0], self.list_commands(ctx))
50+
suggestion = format_suggestions(matches)
51+
if suggestion:
52+
message = f"{e}\n{suggestion}"
53+
raise click.exceptions.UsageError(message, ctx=ctx)
54+
raise
55+
56+
57+
# Install suggestion handling for all CLI command groups.
58+
click.Group.resolve_command = _resolve_command_with_suggestions # type: ignore[assignment]
59+
60+
3161
@click.group()
3262
@click.version_option(version=__version__, prog_name="unity-mcp")
3363
@click.option(
@@ -212,6 +242,8 @@ def register_optional_command(module_name: str, command_name: str) -> None:
212242
cli.add_command(command)
213243

214244
optional_commands = [
245+
("cli.commands.tool", "tool"),
246+
("cli.commands.tool", "custom_tool"),
215247
("cli.commands.gameobject", "gameobject"),
216248
("cli.commands.component", "component"),
217249
("cli.commands.scene", "scene"),

Server/src/cli/utils/connection.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,40 @@ async def list_unity_instances(config: Optional[CLIConfig] = None) -> Dict[str,
189189
def run_list_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
190190
"""Synchronous wrapper for list_unity_instances."""
191191
return asyncio.run(list_unity_instances(config))
192+
193+
194+
async def list_custom_tools(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
195+
"""List custom tools registered for the active Unity project."""
196+
cfg = config or get_config()
197+
url = f"http://{cfg.host}:{cfg.port}/api/custom-tools"
198+
params: Dict[str, Any] = {}
199+
if cfg.unity_instance:
200+
params["instance"] = cfg.unity_instance
201+
202+
try:
203+
async with httpx.AsyncClient() as client:
204+
response = await client.get(url, params=params, timeout=cfg.timeout)
205+
response.raise_for_status()
206+
return response.json()
207+
except httpx.ConnectError as e:
208+
raise UnityConnectionError(
209+
f"Cannot connect to Unity MCP server at {cfg.host}:{cfg.port}. "
210+
f"Make sure the server is running and Unity is connected.\n"
211+
f"Error: {e}"
212+
)
213+
except httpx.TimeoutException:
214+
raise UnityConnectionError(
215+
f"Connection to Unity timed out after {cfg.timeout}s. "
216+
f"Unity may be busy or unresponsive."
217+
)
218+
except httpx.HTTPStatusError as e:
219+
raise UnityConnectionError(
220+
f"HTTP error from server: {e.response.status_code} - {e.response.text}"
221+
)
222+
except Exception as e:
223+
raise UnityConnectionError(f"Unexpected error: {e}")
224+
225+
226+
def run_list_custom_tools(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
227+
"""Synchronous wrapper for list_custom_tools."""
228+
return asyncio.run(list_custom_tools(config))
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Helpers for CLI suggestion messages."""
2+
3+
from __future__ import annotations
4+
5+
import difflib
6+
from typing import Iterable, List
7+
8+
9+
def suggest_matches(
10+
value: str,
11+
choices: Iterable[str],
12+
*,
13+
limit: int = 3,
14+
cutoff: float = 0.6,
15+
) -> List[str]:
16+
"""Return close matches for a value from a list of choices."""
17+
try:
18+
normalized = [c for c in choices if isinstance(c, str)]
19+
except Exception:
20+
normalized = []
21+
if not value or not normalized:
22+
return []
23+
return difflib.get_close_matches(value, normalized, n=limit, cutoff=cutoff)
24+
25+
26+
def format_suggestions(matches: Iterable[str]) -> str | None:
27+
"""Format matches into a CLI-friendly suggestion string."""
28+
items = [m for m in matches if m]
29+
if not items:
30+
return None
31+
if len(items) == 1:
32+
return f"Did you mean: {items[0]}"
33+
joined = ", ".join(items)
34+
return f"Did you mean one of: {joined}"

0 commit comments

Comments
 (0)