From 6650e72cdfa0b86f097583e265e4aee7497cdee2 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:31:19 -0500 Subject: [PATCH] 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! --- Server/src/cli/CLI_USAGE_GUIDE.md | 12 ++ Server/src/cli/commands/editor.py | 25 ++++- Server/src/cli/commands/tool.py | 61 ++++++++++ Server/src/cli/main.py | 32 ++++++ Server/src/cli/utils/connection.py | 37 +++++++ Server/src/cli/utils/suggestions.py | 34 ++++++ Server/src/main.py | 119 +++++++++++++++++++- docs/guides/CLI_EXAMPLE.md | 166 ++++++++++++++++++++++++++++ docs/guides/CLI_USAGE.md | 6 + docs/reference/CUSTOM_TOOLS.md | 16 +++ 10 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 Server/src/cli/commands/tool.py create mode 100644 Server/src/cli/utils/suggestions.py create mode 100644 docs/guides/CLI_EXAMPLE.md diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index 2a8bfb2..5761964 100644 --- a/Server/src/cli/CLI_USAGE_GUIDE.md +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -533,6 +533,18 @@ unity-mcp editor tests unity-mcp editor tests --mode PlayMode ``` +### Custom Tools + +```bash +# List custom tools / default tools for the active Unity project +unity-mcp tool list +unity-mcp custom_tool list + +# Execute a custom tool by name +unity-mcp editor custom-tool "MyBuildTool" +unity-mcp editor custom-tool "Deploy" --params '{"target": "Android"}' +``` + ### Prefab Commands ```bash diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index c76e580..adbd2ae 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -6,7 +6,8 @@ from typing import Optional, Any from cli.utils.config import get_config from cli.utils.output import format_output, print_error, print_success, print_info -from cli.utils.connection import run_command, UnityConnectionError +from cli.utils.connection import run_command, run_list_custom_tools, UnityConnectionError +from cli.utils.suggestions import suggest_matches, format_suggestions @click.group() @@ -472,6 +473,8 @@ def custom_tool(tool_name: str, params: str): params_dict = json.loads(params) except json.JSONDecodeError as e: print_error(f"Invalid JSON for params: {e}") + print_info("Example: --params '{\"key\":\"value\"}'") + print_info("Tip: wrap JSON in single quotes to avoid shell escaping issues.") sys.exit(1) try: @@ -482,6 +485,26 @@ def custom_tool(tool_name: str, params: str): click.echo(format_output(result, config.format)) if result.get("success"): print_success(f"Executed custom tool: {tool_name}") + else: + message = (result.get("message") or result.get("error") or "").lower() + if "not found" in message and "tool" in message: + try: + tools_result = run_list_custom_tools(config) + tools = tools_result.get("tools") + if tools is None: + data = tools_result.get("data", {}) + tools = data.get("tools") if isinstance(data, dict) else None + names = [ + t.get("name") for t in tools if isinstance(t, dict) and t.get("name") + ] if isinstance(tools, list) else [] + matches = suggest_matches(tool_name, names) + suggestion = format_suggestions(matches) + if suggestion: + print_info(suggestion) + print_info( + f'Example: unity-mcp editor custom-tool "{matches[0]}"') + except UnityConnectionError: + pass except UnityConnectionError as e: print_error(str(e)) sys.exit(1) diff --git a/Server/src/cli/commands/tool.py b/Server/src/cli/commands/tool.py new file mode 100644 index 0000000..2aa9082 --- /dev/null +++ b/Server/src/cli/commands/tool.py @@ -0,0 +1,61 @@ +"""Tool CLI commands for listing custom tools.""" + +import sys +import click + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error +from cli.utils.connection import run_list_custom_tools, UnityConnectionError + + +def _list_custom_tools() -> None: + config = get_config() + try: + result = run_list_custom_tools(config) + if config.format != "text": + click.echo(format_output(result, config.format)) + return + + if not isinstance(result, dict) or not result.get("success", True): + click.echo(format_output(result, config.format)) + return + + tools = result.get("tools") + if tools is None: + data = result.get("data", {}) + tools = data.get("tools") if isinstance(data, dict) else None + if not isinstance(tools, list): + click.echo(format_output(result, config.format)) + return + + click.echo(f"Custom tools ({len(tools)}):") + for i, tool in enumerate(tools): + name = tool.get("name") if isinstance(tool, dict) else str(tool) + click.echo(f" [{i}] {name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@click.group("tool") +def tool(): + """Tool management - list custom tools for the active Unity project.""" + pass + + +@tool.command("list") +def list_tools(): + """List custom tools registered for the active Unity project.""" + _list_custom_tools() + + +@click.group("custom_tool") +def custom_tool(): + """Alias for tool management (custom tools).""" + pass + + +@custom_tool.command("list") +def list_custom_tools(): + """List custom tools registered for the active Unity project.""" + _list_custom_tools() diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index 06e517e..ceb3f22 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -8,6 +8,7 @@ from typing import Optional from cli import __version__ from cli.utils.config import CLIConfig, set_config, get_config +from cli.utils.suggestions import suggest_matches, format_suggestions from cli.utils.output import format_output, print_error, print_success, print_info from cli.utils.connection import ( run_command, @@ -28,6 +29,35 @@ class Context: pass_context = click.make_pass_decorator(Context, ensure=True) +_ORIGINAL_RESOLVE_COMMAND = click.Group.resolve_command + + +def _resolve_command_with_suggestions(self: click.Group, ctx: click.Context, args: list[str]): + try: + return _ORIGINAL_RESOLVE_COMMAND(self, ctx, args) + except click.exceptions.NoSuchCommand as e: + if not args or args[0].startswith("-"): + raise + matches = suggest_matches(args[0], self.list_commands(ctx)) + suggestion = format_suggestions(matches) + if suggestion: + message = f"{e}\n{suggestion}" + raise click.exceptions.UsageError(message, ctx=ctx) + raise + except click.exceptions.UsageError as e: + if args and not args[0].startswith("-") and "No such command" in str(e): + matches = suggest_matches(args[0], self.list_commands(ctx)) + suggestion = format_suggestions(matches) + if suggestion: + message = f"{e}\n{suggestion}" + raise click.exceptions.UsageError(message, ctx=ctx) + raise + + +# Install suggestion handling for all CLI command groups. +click.Group.resolve_command = _resolve_command_with_suggestions # type: ignore[assignment] + + @click.group() @click.version_option(version=__version__, prog_name="unity-mcp") @click.option( @@ -212,6 +242,8 @@ def register_commands(): cli.add_command(command) optional_commands = [ + ("cli.commands.tool", "tool"), + ("cli.commands.tool", "custom_tool"), ("cli.commands.gameobject", "gameobject"), ("cli.commands.component", "component"), ("cli.commands.scene", "scene"), diff --git a/Server/src/cli/utils/connection.py b/Server/src/cli/utils/connection.py index 33924e8..22982aa 100644 --- a/Server/src/cli/utils/connection.py +++ b/Server/src/cli/utils/connection.py @@ -189,3 +189,40 @@ async def list_unity_instances(config: Optional[CLIConfig] = None) -> Dict[str, def run_list_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]: """Synchronous wrapper for list_unity_instances.""" return asyncio.run(list_unity_instances(config)) + + +async def list_custom_tools(config: Optional[CLIConfig] = None) -> Dict[str, Any]: + """List custom tools registered for the active Unity project.""" + cfg = config or get_config() + url = f"http://{cfg.host}:{cfg.port}/api/custom-tools" + params: Dict[str, Any] = {} + if cfg.unity_instance: + params["instance"] = cfg.unity_instance + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params, timeout=cfg.timeout) + response.raise_for_status() + return response.json() + except httpx.ConnectError as e: + raise UnityConnectionError( + f"Cannot connect to Unity MCP server at {cfg.host}:{cfg.port}. " + f"Make sure the server is running and Unity is connected.\n" + f"Error: {e}" + ) + except httpx.TimeoutException: + raise UnityConnectionError( + f"Connection to Unity timed out after {cfg.timeout}s. " + f"Unity may be busy or unresponsive." + ) + except httpx.HTTPStatusError as e: + raise UnityConnectionError( + f"HTTP error from server: {e.response.status_code} - {e.response.text}" + ) + except Exception as e: + raise UnityConnectionError(f"Unexpected error: {e}") + + +def run_list_custom_tools(config: Optional[CLIConfig] = None) -> Dict[str, Any]: + """Synchronous wrapper for list_custom_tools.""" + return asyncio.run(list_custom_tools(config)) diff --git a/Server/src/cli/utils/suggestions.py b/Server/src/cli/utils/suggestions.py new file mode 100644 index 0000000..3b6dc67 --- /dev/null +++ b/Server/src/cli/utils/suggestions.py @@ -0,0 +1,34 @@ +"""Helpers for CLI suggestion messages.""" + +from __future__ import annotations + +import difflib +from typing import Iterable, List + + +def suggest_matches( + value: str, + choices: Iterable[str], + *, + limit: int = 3, + cutoff: float = 0.6, +) -> List[str]: + """Return close matches for a value from a list of choices.""" + try: + normalized = [c for c in choices if isinstance(c, str)] + except Exception: + normalized = [] + if not value or not normalized: + return [] + return difflib.get_close_matches(value, normalized, n=limit, cutoff=cutoff) + + +def format_suggestions(matches: Iterable[str]) -> str | None: + """Format matches into a CLI-friendly suggestion string.""" + items = [m for m in matches if m] + if not items: + return None + if len(items) == 1: + return f"Did you mean: {items[0]}" + joined = ", ".join(items) + return f"Did you mean one of: {joined}" diff --git a/Server/src/main.py b/Server/src/main.py index 7755fc6..79eb7de 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -9,7 +9,10 @@ from core.telemetry import record_milestone, record_telemetry, MilestoneType, Re from services.resources import register_all_resources from transport.plugin_registry import PluginRegistry from transport.plugin_hub import PluginHub -from services.custom_tool_service import CustomToolService +from services.custom_tool_service import ( + CustomToolService, + resolve_project_id_for_unity_instance, +) from core.config import config from starlette.routing import WebSocketRoute from starlette.responses import JSONResponse @@ -325,6 +328,14 @@ def create_mcp_server(project_scoped_tools: bool) -> FastMCP: "message": "MCP for Unity server is running" }) + def _normalize_instance_token(instance_token: str | None) -> tuple[str | None, str | None]: + if not instance_token: + return None, None + if "@" in instance_token: + name_part, _, hash_part = instance_token.partition("@") + return (name_part or None), (hash_part or None) + return None, instance_token + @mcp.custom_route("/api/command", methods=["POST"]) async def cli_command_route(request: Request) -> JSONResponse: """REST endpoint for CLI commands to Unity.""" @@ -348,11 +359,14 @@ def create_mcp_server(project_scoped_tools: bool) -> FastMCP: # Find target session session_id = None + session_details = None + instance_name, instance_hash = _normalize_instance_token(unity_instance) if unity_instance: # Try to match by hash or project name for sid, details in sessions.sessions.items(): - if details.hash == unity_instance or details.project == unity_instance: + if details.hash == instance_hash or details.project in (instance_name, unity_instance): session_id = sid + session_details = details break # If a specific unity_instance was requested but not found, return an error @@ -367,6 +381,46 @@ def create_mcp_server(project_scoped_tools: bool) -> FastMCP: else: # No specific unity_instance requested: use first available session session_id = next(iter(sessions.sessions.keys())) + session_details = sessions.sessions.get(session_id) + + if command_type == "execute_custom_tool": + tool_name = None + tool_params = {} + if isinstance(params, dict): + tool_name = params.get("tool_name") or params.get("name") + tool_params = params.get("parameters") or params.get("params") or {} + + if not tool_name: + return JSONResponse( + {"success": False, "error": "Missing 'tool_name' for execute_custom_tool"}, + status_code=400, + ) + if tool_params is None: + tool_params = {} + if not isinstance(tool_params, dict): + return JSONResponse( + {"success": False, "error": "Tool parameters must be an object/dict"}, + status_code=400, + ) + + # Prefer a concrete hash for project-scoped tools. + unity_instance_hint = unity_instance + if session_details and session_details.hash: + unity_instance_hint = session_details.hash + + project_id = resolve_project_id_for_unity_instance( + unity_instance_hint) + if not project_id: + return JSONResponse( + {"success": False, "error": "Could not resolve project id for custom tool"}, + status_code=400, + ) + + service = CustomToolService.get_instance() + result = await service.execute_tool( + project_id, tool_name, unity_instance_hint, tool_params + ) + return JSONResponse(result.model_dump()) # Send command to Unity result = await PluginHub.send_command(session_id, command_type, params) @@ -376,6 +430,67 @@ def create_mcp_server(project_scoped_tools: bool) -> FastMCP: logger.error(f"CLI command error: {e}") return JSONResponse({"success": False, "error": str(e)}, status_code=500) + @mcp.custom_route("/api/custom-tools", methods=["GET"]) + async def cli_custom_tools_route(request: Request) -> JSONResponse: + """REST endpoint to list custom tools for the active Unity project.""" + try: + unity_instance = request.query_params.get("instance") + instance_name, instance_hash = _normalize_instance_token(unity_instance) + + sessions = await PluginHub.get_sessions() + if not sessions.sessions: + return JSONResponse({ + "success": False, + "error": "No Unity instances connected. Make sure Unity is running with MCP plugin." + }, status_code=503) + + session_details = None + if unity_instance: + # Try to match by hash or project name + for _, details in sessions.sessions.items(): + if details.hash == instance_hash or details.project in (instance_name, unity_instance): + session_details = details + break + if not session_details: + return JSONResponse( + { + "success": False, + "error": f"Unity instance '{unity_instance}' not found", + }, + status_code=404, + ) + else: + # No specific unity_instance requested: use first available session + session_details = next(iter(sessions.sessions.values())) + + unity_instance_hint = unity_instance + if session_details and session_details.hash: + unity_instance_hint = session_details.hash + + project_id = resolve_project_id_for_unity_instance( + unity_instance_hint) + if not project_id: + return JSONResponse( + {"success": False, "error": "Could not resolve project id for custom tools"}, + status_code=400, + ) + + service = CustomToolService.get_instance() + tools = await service.list_registered_tools(project_id) + tools_payload = [ + tool.model_dump() if hasattr(tool, "model_dump") else tool for tool in tools + ] + + return JSONResponse({ + "success": True, + "project_id": project_id, + "tool_count": len(tools_payload), + "tools": tools_payload, + }) + except Exception as e: + logger.error(f"CLI custom tools error: {e}") + return JSONResponse({"success": False, "error": str(e)}, status_code=500) + @mcp.custom_route("/api/instances", methods=["GET"]) async def cli_instances_route(_: Request) -> JSONResponse: """REST endpoint to list connected Unity instances.""" diff --git a/docs/guides/CLI_EXAMPLE.md b/docs/guides/CLI_EXAMPLE.md new file mode 100644 index 0000000..ab3198c --- /dev/null +++ b/docs/guides/CLI_EXAMPLE.md @@ -0,0 +1,166 @@ +## Unity MCP (CLI Mode) + +We use Unity MCP via **CLI commands** instead of MCP server connection. This avoids the reconnection issues that occur when Unity restarts. + +### Why CLI Instead of MCP Connection? + +- MCP connection breaks when Unity restarts +- `/mcp reconnect` requires human intervention +- CLI works directly via HTTP to the MCP server - no persistent connection needed +- Claude can call CLI commands autonomously without reconnection issues + +### Installation + +```bash +cd Server # In unity-mcp repo +pip install -e . +# Or with uv: +uv pip install -e . +``` + +### Global Options + +| Option | Description | Default | Env Variable | +|--------|-------------|---------|--------------| +| `-h, --host` | Server host | 127.0.0.1 | `UNITY_MCP_HOST` | +| `-p, --port` | Server port | 8080 | `UNITY_MCP_HTTP_PORT` | +| `-t, --timeout` | Timeout seconds | 30 | `UNITY_MCP_TIMEOUT` | +| `-f, --format` | Output: text, json, table | text | `UNITY_MCP_FORMAT` | +| `-i, --instance` | Target Unity instance | - | `UNITY_MCP_INSTANCE` | + +### Core CLI Commands + +**Status & Connection** +```bash +unity-mcp status # Check server + Unity connection +``` + +**Instance Management** +```bash +unity-mcp instance list # List connected Unity instances +unity-mcp instance set "ProjectName@abc" # Set active instance +unity-mcp instance current # Show current instance +``` + +**Editor Control** +```bash +unity-mcp editor play|pause|stop # Control play mode +unity-mcp editor console [--clear] # Get/clear console logs +unity-mcp editor refresh [--compile] # Refresh assets +unity-mcp editor menu "Edit/Project Settings..." # Execute menu item +unity-mcp editor add-tag "TagName" # Add tag +unity-mcp editor add-layer "LayerName" # Add layer +unity-mcp editor tests --mode PlayMode [--async] +unity-mcp editor poll-test [--wait 60] [--details] +unity-mcp --instance "MyProject@abc123" editor play # Target a specific instance +``` + +**Custom Tools** +```bash +unity-mcp tool list +unity-mcp custom_tool list +unity-mcp editor custom-tool "bake_lightmaps" +unity-mcp editor custom-tool "capture_screenshot" --params '{"filename":"shot_01","width":1920,"height":1080}' +``` + +**Scene Operations** +```bash +unity-mcp scene hierarchy [--limit 20] [--depth 3] +unity-mcp scene active +unity-mcp scene load "Assets/Scenes/Main.unity" +unity-mcp scene save +unity-mcp scene screenshot --name "capture" +unity-mcp --format json scene hierarchy +``` + +**GameObject Operations** +```bash +unity-mcp gameobject find "Name" [--method by_tag|by_name|by_layer|by_component] +unity-mcp gameobject create "Name" [--primitive Cube] [--position X Y Z] +unity-mcp gameobject modify "Name" [--position X Y Z] [--rotation X Y Z] +unity-mcp gameobject delete "Name" [--force] +unity-mcp gameobject duplicate "Name" +``` + +**Component Operations** +```bash +unity-mcp component add "GameObject" ComponentType +unity-mcp component remove "GameObject" ComponentType +unity-mcp component set "GameObject" Component property value +``` + +**Script Operations** +```bash +unity-mcp script create "ScriptName" --path "Assets/Scripts" +unity-mcp script read "Assets/Scripts/File.cs" +unity-mcp script delete "Assets/Scripts/File.cs" [--force] +unity-mcp code search "pattern" "path/to/file.cs" [--max-results 20] +``` + +**Asset Operations** +```bash +unity-mcp asset search --pattern "*.mat" --path "Assets/Materials" +unity-mcp asset info "Assets/Materials/File.mat" +unity-mcp asset mkdir "Assets/NewFolder" +unity-mcp asset move "Old/Path" "New/Path" +``` + +**Prefab Operations** +```bash +unity-mcp prefab open "Assets/Prefabs/File.prefab" +unity-mcp prefab save +unity-mcp prefab close +unity-mcp prefab create "GameObject" --path "Assets/Prefabs" +``` + +**Material Operations** +```bash +unity-mcp material create "Assets/Materials/File.mat" +unity-mcp material set-color "File.mat" R G B +unity-mcp material assign "File.mat" "GameObject" +``` + +**Shader Operations** +```bash +unity-mcp shader create "Name" --path "Assets/Shaders" +unity-mcp shader read "Assets/Shaders/Custom.shader" +unity-mcp shader update "Assets/Shaders/Custom.shader" --file local.shader +unity-mcp shader delete "Assets/Shaders/File.shader" [--force] +``` + +**VFX Operations** +```bash +unity-mcp vfx particle info|play|stop|pause|restart|clear "Name" +unity-mcp vfx line info "Name" +unity-mcp vfx line create-line "Name" --start X Y Z --end X Y Z +unity-mcp vfx line create-circle "Name" --radius N +unity-mcp vfx trail info|set-time|clear "Name" [time] +``` + +**Lighting & UI** +```bash +unity-mcp lighting create "Name" --type Point|Spot [--intensity N] [--position X Y Z] +unity-mcp ui create-canvas "Name" +unity-mcp ui create-text "Name" --parent "Canvas" --text "Content" +unity-mcp ui create-button "Name" --parent "Canvas" --text "Label" +``` + +**Batch Operations** +```bash +unity-mcp batch run commands.json [--parallel] [--fail-fast] +unity-mcp batch inline '[{"tool": "manage_scene", "params": {...}}]' +unity-mcp batch template > commands.json +``` + +**Raw Access (Any Tool)** +```bash +unity-mcp raw tool_name 'JSON_params' +unity-mcp raw manage_scene '{"action":"get_active"}' +``` + +### Note on MCP Server + +The MCP HTTP server still needs to be running for CLI to work. Here is an example to run the server manually on Mac: +```bash +/opt/homebrew/bin/uvx --no-cache --refresh --from /XXX/unity-mcp/Server mcp-for-unity --transport http --http-url http://localhost:8080 +``` diff --git a/docs/guides/CLI_USAGE.md b/docs/guides/CLI_USAGE.md index ad106e7..518fc16 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -168,6 +168,10 @@ unity-mcp editor menu "Edit/Project Settings..." # Custom tools unity-mcp editor custom-tool "MyBuildTool" unity-mcp editor custom-tool "Deploy" --params '{"target": "Android"}' + +# List custom tools for the active Unity project +unity-mcp tool list +unity-mcp custom_tool list ``` ### Testing @@ -344,6 +348,8 @@ unity-mcp raw read_console '{"count": 20}' | `animation` | `play`, `set-parameter` | | `audio` | `play`, `stop`, `volume` | | `lighting` | `create` | +| `tool` | `list` | +| `custom_tool` | `list` | | `ui` | `create-canvas`, `create-text`, `create-button`, `create-image` | --- diff --git a/docs/reference/CUSTOM_TOOLS.md b/docs/reference/CUSTOM_TOOLS.md index 5be7e19..013b2b4 100644 --- a/docs/reference/CUSTOM_TOOLS.md +++ b/docs/reference/CUSTOM_TOOLS.md @@ -73,6 +73,22 @@ Once you've created your tool, you'll need to let your AI assistant know about i **If that doesn't work:** Some clients (like Windsurf) may need you to remove and reconfigure the MCP for Unity server entirely. It's a bit more work, but it guarantees your new tools will appear. +## Step 3: List and Call Your Tool from the CLI + +If you want to use the CLI directly, list custom tools for the active Unity project: + +```bash +unity-mcp tool list +unity-mcp custom_tool list +``` + +Then call your tool by name: + +```bash +unity-mcp editor custom-tool "my_custom_tool" +unity-mcp editor custom-tool "my_custom_tool" --params '{"param1":"value"}' +``` + ## Complete Example: Screenshot Tool ### C# Handler (`Assets/Editor/ScreenShots/CaptureScreenshotTool.cs`)