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!
main
Shutong Wu 2026-01-26 19:31:19 -05:00 committed by GitHub
parent 17eb171e31
commit 6650e72cdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 505 additions and 3 deletions

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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"),

View File

@ -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))

View File

@ -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}"

View File

@ -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."""

166
docs/guides/CLI_EXAMPLE.md Normal file
View File

@ -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 <job_id> [--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
```

View File

@ -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` |
---

View File

@ -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`)