From a9ef26246f192951e69eb7acaceb933fbd420aea Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 10 Jan 2026 22:09:49 -0500 Subject: [PATCH 01/24] feat: Add CLI for Unity MCP server - Add click-based CLI with 15+ command groups - Commands: gameobject, component, scene, asset, script, editor, prefab, material, lighting, ui, audio, animation, code - HTTP transport to communicate with Unity via MCP server - Output formats: text, json, table - Configuration via environment variables or CLI options - Comprehensive usage guide and unit tests --- Server/pyproject.toml | 2 + Server/src/cli/CLI_USAGE_GUIDE.md | 727 +++++++++++++++++++++ Server/src/cli/__init__.py | 3 + Server/src/cli/commands/__init__.py | 3 + Server/src/cli/commands/animation.py | 85 +++ Server/src/cli/commands/asset.py | 307 +++++++++ Server/src/cli/commands/audio.py | 130 ++++ Server/src/cli/commands/code.py | 71 +++ Server/src/cli/commands/component.py | 212 +++++++ Server/src/cli/commands/editor.py | 299 +++++++++ Server/src/cli/commands/gameobject.py | 497 +++++++++++++++ Server/src/cli/commands/lighting.py | 112 ++++ Server/src/cli/commands/material.py | 266 ++++++++ Server/src/cli/commands/prefab.py | 143 +++++ Server/src/cli/commands/scene.py | 254 ++++++++ Server/src/cli/commands/script.py | 229 +++++++ Server/src/cli/commands/ui.py | 228 +++++++ Server/src/cli/main.py | 273 ++++++++ Server/src/cli/utils/__init__.py | 31 + Server/src/cli/utils/config.py | 45 ++ Server/src/cli/utils/connection.py | 190 ++++++ Server/src/cli/utils/output.py | 188 ++++++ Server/src/main.py | 61 ++ Server/tests/test_cli.py | 877 ++++++++++++++++++++++++++ Server/uv.lock | 2 + 25 files changed, 5235 insertions(+) create mode 100644 Server/src/cli/CLI_USAGE_GUIDE.md create mode 100644 Server/src/cli/__init__.py create mode 100644 Server/src/cli/commands/__init__.py create mode 100644 Server/src/cli/commands/animation.py create mode 100644 Server/src/cli/commands/asset.py create mode 100644 Server/src/cli/commands/audio.py create mode 100644 Server/src/cli/commands/code.py create mode 100644 Server/src/cli/commands/component.py create mode 100644 Server/src/cli/commands/editor.py create mode 100644 Server/src/cli/commands/gameobject.py create mode 100644 Server/src/cli/commands/lighting.py create mode 100644 Server/src/cli/commands/material.py create mode 100644 Server/src/cli/commands/prefab.py create mode 100644 Server/src/cli/commands/scene.py create mode 100644 Server/src/cli/commands/script.py create mode 100644 Server/src/cli/commands/ui.py create mode 100644 Server/src/cli/main.py create mode 100644 Server/src/cli/utils/__init__.py create mode 100644 Server/src/cli/utils/config.py create mode 100644 Server/src/cli/utils/connection.py create mode 100644 Server/src/cli/utils/output.py create mode 100644 Server/tests/test_cli.py diff --git a/Server/pyproject.toml b/Server/pyproject.toml index c86d3ef96..5389e7ff8 100644 --- a/Server/pyproject.toml +++ b/Server/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "tomli>=2.3.0", "fastapi>=0.104.0", "uvicorn>=0.35.0", + "click>=8.1.0", ] [project.optional-dependencies] @@ -51,6 +52,7 @@ Issues = "https://github.com/CoplayDev/unity-mcp/issues" [project.scripts] mcp-for-unity = "main:main" +unity-mcp = "cli.main:main" [build-system] requires = ["setuptools>=64.0.0", "wheel"] diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md new file mode 100644 index 000000000..2a8bfb257 --- /dev/null +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -0,0 +1,727 @@ +# Unity MCP CLI Usage Guide + +> **For AI Assistants and Developers**: This document explains the correct syntax and common pitfalls when using the Unity MCP CLI. + +## Table of Contents + +1. [Installation](#installation) +2. [Quick Start](#quick-start) +3. [Command Structure](#command-structure) +4. [Global Options](#global-options) +5. [Argument vs Option Syntax](#argument-vs-option-syntax) +6. [Common Mistakes and Corrections](#common-mistakes-and-corrections) +7. [Output Formats](#output-formats) +8. [Command Reference by Category](#command-reference-by-category) + +--- + +## Installation + +### Prerequisites + +- **Python 3.10+** installed +- **Unity Editor** running with the MCP plugin enabled +- **MCP Server** running (HTTP transport on port 8080) + +### Install via pip (from source) + +```bash +# Navigate to the Server directory +cd /path/to/unity-mcp/Server + +# Install in development mode +pip install -e . + +# Or install with uv (recommended) +uv pip install -e . +``` + +### Install via uv tool + +```bash +# Run directly without installing +uvx --from /path/to/unity-mcp/Server unity-mcp --help + +# Or install as a tool +uv tool install /path/to/unity-mcp/Server +``` + +### Verify Installation + +```bash +# Check version +unity-mcp --version + +# Check help +unity-mcp --help + +# Test connection to Unity +unity-mcp status +``` + +--- + +## Quick Start + +### 1. Start the MCP Server + +Make sure the Unity MCP server is running with HTTP transport: + +```bash +# The server is typically started via the Unity-MCP window, select HTTP local, and start server, or try this manually: +cd /path/to/unity-mcp/Server +uv run mcp-for-unity --transport http --http-url http://localhost:8080 +``` + +### 2. Verify Connection + +```bash +unity-mcp status +``` + +Expected output: +``` +Checking connection to 127.0.0.1:8080... +✅ Connected to Unity MCP server at 127.0.0.1:8080 + +Connected Unity instances: + • MyProject (Unity 6000.2.10f1) [09abcc51] +``` + +### 3. Run Your First Commands + +```bash +# Get scene hierarchy +unity-mcp scene hierarchy + +# Create a cube +unity-mcp gameobject create "MyCube" --primitive Cube + +# Move the cube +unity-mcp gameobject modify "MyCube" --position 0 2 0 + +# Take a screenshot +unity-mcp scene screenshot + +# Enter play mode +unity-mcp editor play +``` + +### 4. Get Help on Any Command + +```bash +# List all commands +unity-mcp --help + +# Help for a command group +unity-mcp gameobject --help + +# Help for a specific command +unity-mcp gameobject create --help +``` + +--- + +## Command Structure + +The CLI follows this general pattern: + +``` +unity-mcp [GLOBAL_OPTIONS] COMMAND_GROUP [SUBCOMMAND] [ARGUMENTS] [OPTIONS] +``` + +**Example breakdown:** +```bash +unity-mcp -f json gameobject create "MyCube" --primitive Cube --position 0 1 0 +# ^^^^^^^ ^^^^^^^^^^^ ^^^^^^ ^^^^^^^^ ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ +# global cmd group subcmd argument option multi-value option +``` + +--- + +## Global Options + +Global options come **BEFORE** the command group: + +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--host` | `-h` | MCP server host | `127.0.0.1` | +| `--port` | `-p` | MCP server port | `8080` | +| `--format` | `-f` | Output format: `text`, `json`, `table` | `text` | +| `--timeout` | `-t` | Command timeout in seconds | `30` | +| `--instance` | `-i` | Target Unity instance (hash or Name@hash) | auto | +| `--verbose` | `-v` | Enable verbose output | `false` | + +**✅ Correct:** +```bash +unity-mcp -f json scene hierarchy +unity-mcp --format json --timeout 60 gameobject find "Player" +``` + +**❌ Wrong:** +```bash +unity-mcp scene hierarchy -f json # Global option after command +``` + +--- + +## Argument vs Option Syntax + +### Arguments (Positional) +Arguments are **required values** that come in a specific order, **without** flags. + +```bash +unity-mcp gameobject find "Player" +# ^^^^^^^^ This is an ARGUMENT (positional) +``` + +### Options (Named) +Options use `--name` or `-n` flags and can appear in any order after arguments. + +```bash +unity-mcp gameobject create "MyCube" --primitive Cube +# ^^^^^^^^^^^ ^^^^ This is an OPTION with value +``` + +### Multi-Value Options +Some options accept multiple values. **Do NOT use commas** - use spaces: + +**✅ Correct:** +```bash +unity-mcp gameobject modify "Cube" --position 1 2 3 +unity-mcp gameobject modify "Cube" --rotation 0 45 0 +unity-mcp gameobject modify "Cube" --scale 2 2 2 +``` + +**❌ Wrong:** +```bash +unity-mcp gameobject modify "Cube" --position "1,2,3" # Wrong: comma-separated string +unity-mcp gameobject modify "Cube" --position 1,2,3 # Wrong: comma-separated +unity-mcp gameobject modify "Cube" -pos "1 2 3" # Wrong: quoted as single string +``` + +--- + +## Common Mistakes and Corrections + +### 1. Multi-Value Options (Position, Rotation, Scale, Color) + +These options expect **separate float arguments**, not comma-separated strings: + +| Option | ❌ Wrong | ✅ Correct | +|--------|----------|-----------| +| `--position` | `--position "2,1,0"` | `--position 2 1 0` | +| `--rotation` | `--rotation "0,45,0"` | `--rotation 0 45 0` | +| `--scale` | `--scale "1,1,1"` | `--scale 1 1 1` | +| Color args | `1,0,0,1` | `1 0 0 1` | + +**Example - Moving a GameObject:** +```bash +# Wrong - will error "requires 3 arguments" +unity-mcp gameobject modify "Cube" --position "2,1,0" + +# Correct +unity-mcp gameobject modify "Cube" --position 2 1 0 +``` + +**Example - Setting material color:** +```bash +# Wrong +unity-mcp material set-color "Assets/Mat.mat" 1,0,0,1 + +# Correct (R G B or R G B A as separate args) +unity-mcp material set-color "Assets/Mat.mat" 1 0 0 +unity-mcp material set-color "Assets/Mat.mat" 1 0 0 1 +``` + +### 2. Argument Order Matters + +Some commands have multiple positional arguments. Check `--help` to see the order: + +**Material assign:** +```bash +# Wrong - arguments in wrong order +unity-mcp material assign "TestCube" "Assets/Materials/Red.mat" + +# ✅ Correct - MATERIAL_PATH comes before TARGET +unity-mcp material assign "Assets/Materials/Red.mat" "TestCube" +``` + +**Prefab create:** +```bash +# Wrong - using --path option that doesn't exist +unity-mcp prefab create "Cube" --path "Assets/Prefabs/Cube.prefab" + +# Correct - PATH is a positional argument +unity-mcp prefab create "Cube" "Assets/Prefabs/Cube.prefab" +``` + +### 3. Using Options That Don't Exist + +Always check `--help` before assuming an option exists: + +```bash +# Check available options for any command +unity-mcp gameobject modify --help +unity-mcp material assign --help +unity-mcp prefab create --help +``` + +### 4. Property Names for Materials + +Different shaders use different property names. Use `material info` to discover them: + +```bash +# First, check what properties exist +unity-mcp material info "Assets/Materials/MyMat.mat" + +# Then use the correct property name +# For URP shaders, often "_BaseColor" instead of "_Color" +unity-mcp material set-color "Assets/Mat.mat" 1 0 0 --property "_BaseColor" +``` + +### 5. Search Methods + +When targeting GameObjects, specify how to search: + +```bash +# By name (default) +unity-mcp gameobject modify "Player" --position 0 0 0 + +# By instance ID (use --search-method) +unity-mcp gameobject modify "-81840" --search-method by_id --position 0 0 0 + +# By path +unity-mcp gameobject modify "/Canvas/Panel/Button" --search-method by_path --active + +# By tag +unity-mcp gameobject find "Player" --search-method by_tag +``` + +--- + +## Output Formats + +### Text (Default) +Human-readable nested format: +```bash +unity-mcp scene active +# Output: +# status: success +# result: +# name: New Scene +# path: Assets/Scenes/New Scene.unity +# ... +``` + +### JSON +Machine-readable JSON: +```bash +unity-mcp -f json scene active +# Output: {"status": "success", "result": {...}} +``` + +### Table +Key-value table format: +```bash +unity-mcp -f table scene active +# Output: +# Key | Value +# -------+------ +# status | success +# ... +``` + +--- + +## Command Reference by Category + +### Status & Connection + +```bash +# Check server connection and Unity instances +unity-mcp status + +# List connected Unity instances +unity-mcp instances +``` + +### Scene Commands + +```bash +# Get scene hierarchy +unity-mcp scene hierarchy + +# Get active scene info +unity-mcp scene active + +# Get build settings +unity-mcp scene build-settings + +# Create new scene +unity-mcp scene create "MyScene" + +# Load scene +unity-mcp scene load "Assets/Scenes/MyScene.unity" + +# Save current scene +unity-mcp scene save + +# Take screenshot +unity-mcp scene screenshot +unity-mcp scene screenshot --filename "my_screenshot" --supersize 2 +``` + +### GameObject Commands + +```bash +# Find GameObjects +unity-mcp gameobject find "Player" +unity-mcp gameobject find "Enemy" --method by_tag +unity-mcp gameobject find "-81840" --method by_id +unity-mcp gameobject find "Rigidbody" --method by_component + +# Create GameObject +unity-mcp gameobject create "Empty" # Empty object +unity-mcp gameobject create "MyCube" --primitive Cube # Primitive +unity-mcp gameobject create "MyObj" --position 0 5 0 # With position +unity-mcp gameobject create "Player" --components "Rigidbody,BoxCollider" # With components + +# Modify GameObject +unity-mcp gameobject modify "Cube" --position 1 2 3 +unity-mcp gameobject modify "Cube" --rotation 0 45 0 +unity-mcp gameobject modify "Cube" --scale 2 2 2 +unity-mcp gameobject modify "Cube" --name "NewName" +unity-mcp gameobject modify "Cube" --active # Enable +unity-mcp gameobject modify "Cube" --inactive # Disable +unity-mcp gameobject modify "Cube" --tag "Player" +unity-mcp gameobject modify "Cube" --parent "Parent" + +# Delete GameObject +unity-mcp gameobject delete "Cube" +unity-mcp gameobject delete "Cube" --force # Skip confirmation + +# Duplicate GameObject +unity-mcp gameobject duplicate "Cube" + +# Move relative to another object +unity-mcp gameobject move "Cube" --reference "Player" --direction up --distance 2 +``` + +### Component Commands + +```bash +# Add component +unity-mcp component add "Cube" Rigidbody +unity-mcp component add "Cube" BoxCollider + +# Remove component +unity-mcp component remove "Cube" Rigidbody +unity-mcp component remove "Cube" Rigidbody --force # Skip confirmation + +# Set single property +unity-mcp component set "Cube" Rigidbody mass 5 +unity-mcp component set "Cube" Rigidbody useGravity false +unity-mcp component set "Cube" Light intensity 2.5 + +# Set multiple properties at once +unity-mcp component modify "Cube" Rigidbody --properties '{"mass": 5, "drag": 0.5}' +``` + +### Asset Commands + +```bash +# Search assets +unity-mcp asset search "Player" +unity-mcp asset search "t:Material" # By type +unity-mcp asset search "t:Prefab Player" # Combined + +# Get asset info +unity-mcp asset info "Assets/Materials/Red.mat" + +# Create asset +unity-mcp asset create "Assets/Materials/New.mat" Material + +# Delete asset +unity-mcp asset delete "Assets/Materials/Old.mat" +unity-mcp asset delete "Assets/Materials/Old.mat" --force # Skip confirmation + +# Move/Rename asset +unity-mcp asset move "Assets/Old/Mat.mat" "Assets/New/Mat.mat" +unity-mcp asset rename "Assets/Materials/Old.mat" "New" + +# Create folder +unity-mcp asset mkdir "Assets/NewFolder" + +# Import/reimport +unity-mcp asset import "Assets/Textures/image.png" +``` + +### Script Commands + +```bash +# Create script +unity-mcp script create "MyScript" --path "Assets/Scripts" +unity-mcp script create "MyScript" --path "Assets/Scripts" --type MonoBehaviour + +# Read script +unity-mcp script read "Assets/Scripts/MyScript.cs" + +# Delete script +unity-mcp script delete "Assets/Scripts/MyScript.cs" + +# Validate script +unity-mcp script validate "Assets/Scripts/MyScript.cs" +``` + +### Material Commands + +```bash +# Create material +unity-mcp material create "Assets/Materials/New.mat" +unity-mcp material create "Assets/Materials/New.mat" --shader "Standard" + +# Get material info +unity-mcp material info "Assets/Materials/Mat.mat" + +# Set color (R G B or R G B A) +unity-mcp material set-color "Assets/Materials/Mat.mat" 1 0 0 +unity-mcp material set-color "Assets/Materials/Mat.mat" 1 0 0 --property "_BaseColor" + +# Set shader property +unity-mcp material set-property "Assets/Materials/Mat.mat" "_Metallic" 0.5 + +# Assign to GameObject +unity-mcp material assign "Assets/Materials/Mat.mat" "Cube" +unity-mcp material assign "Assets/Materials/Mat.mat" "Cube" --slot 1 + +# Set renderer color directly +unity-mcp material set-renderer-color "Cube" 1 0 0 1 +``` + +### Editor Commands + +```bash +# Play mode control +unity-mcp editor play +unity-mcp editor pause +unity-mcp editor stop + +# Console +unity-mcp editor console # Read console +unity-mcp editor console --count 20 # Last 20 entries +unity-mcp editor console --clear # Clear console +unity-mcp editor console --types error,warning # Filter by type + +# Menu items +unity-mcp editor menu "Edit/Preferences" +unity-mcp editor menu "GameObject/Create Empty" + +# Tags and Layers +unity-mcp editor add-tag "Enemy" +unity-mcp editor remove-tag "Enemy" +unity-mcp editor add-layer "Interactable" +unity-mcp editor remove-layer "Interactable" + +# Editor tool +unity-mcp editor tool View +unity-mcp editor tool Move +unity-mcp editor tool Rotate + +# Run tests +unity-mcp editor tests +unity-mcp editor tests --mode PlayMode +``` + +### Prefab Commands + +```bash +# Create prefab from scene object +unity-mcp prefab create "Cube" "Assets/Prefabs/Cube.prefab" +unity-mcp prefab create "Cube" "Assets/Prefabs/Cube.prefab" --overwrite + +# Open prefab for editing +unity-mcp prefab open "Assets/Prefabs/Player.prefab" + +# Save open prefab +unity-mcp prefab save + +# Close prefab stage +unity-mcp prefab close +``` + +### UI Commands + +```bash +# Create a Canvas (adds Canvas, CanvasScaler, GraphicRaycaster) +unity-mcp ui create-canvas "MainCanvas" +unity-mcp ui create-canvas "WorldUI" --render-mode WorldSpace + +# Create UI elements (must have a parent Canvas) +unity-mcp ui create-text "TitleText" --parent "MainCanvas" --text "Hello World" +unity-mcp ui create-button "StartButton" --parent "MainCanvas" --text "Click Me" +unity-mcp ui create-image "Background" --parent "MainCanvas" +``` + +### Lighting Commands + +```bash +# Create lights with type, color, intensity +unity-mcp lighting create "Sun" --type Directional +unity-mcp lighting create "Lamp" --type Point --intensity 2 --position 0 5 0 +unity-mcp lighting create "Spot" --type Spot --color 1 0 0 --intensity 3 +unity-mcp lighting create "GreenLight" --type Point --color 0 1 0 +``` + +### Audio Commands + +```bash +# Control AudioSource (target must have AudioSource component) +unity-mcp audio play "MusicPlayer" +unity-mcp audio stop "MusicPlayer" +unity-mcp audio volume "MusicPlayer" 0.5 +``` + +### Animation Commands + +```bash +# Control Animator (target must have Animator component) +unity-mcp animation play "Character" "Walk" +unity-mcp animation set-parameter "Character" "Speed" 1.5 --type float +unity-mcp animation set-parameter "Character" "IsRunning" true --type bool +unity-mcp animation set-parameter "Character" "Jump" "" --type trigger +``` + +### Code Commands + +```bash +# Read source files +unity-mcp code read "Assets/Scripts/Player.cs" +unity-mcp code read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20 +``` + +### Raw Commands + +For advanced usage, send raw tool calls: + +```bash +# Send any MCP tool directly +unity-mcp raw manage_scene '{"action": "get_active"}' +unity-mcp raw manage_gameobject '{"action": "create", "name": "Test"}' +unity-mcp raw manage_components '{"action": "add", "target": "Test", "componentType": "Rigidbody"}' +unity-mcp raw manage_editor '{"action": "play"}' +``` + +--- + +## Known Behaviors + +### Component Creation + +When creating GameObjects with components, the CLI creates the object first, then adds components separately. This is the correct workflow for Unity MCP. + +```bash +# This works correctly - creates object then adds components +unity-mcp gameobject create "Player" --components "Rigidbody,BoxCollider" + +# Equivalent to: +unity-mcp gameobject create "Player" +unity-mcp component add "Player" Rigidbody +unity-mcp component add "Player" BoxCollider +``` + +### Light Creation + +The `lighting create` command creates a complete light with the specified type, color, and intensity: + +```bash +# Creates Point light with green color and intensity 5 +unity-mcp lighting create "GreenLight" --type Point --color 0 1 0 --intensity 5 +``` + +### UI Element Creation + +UI commands automatically add the required components: + +```bash +# create-canvas adds: Canvas, CanvasScaler, GraphicRaycaster +unity-mcp ui create-canvas "MainUI" + +# create-button adds: Image, Button +unity-mcp ui create-button "MyButton" --parent "MainUI" +``` + +--- + +## Quick Reference Card + +### Multi-Value Syntax + +```bash +--position X Y Z # not "X,Y,Z" +--rotation X Y Z # not "X,Y,Z" +--scale X Y Z # not "X,Y,Z" +--color R G B # not "R,G,B" +``` + +### Argument Order (check --help) + +```bash +material assign MATERIAL_PATH TARGET +prefab create TARGET PATH +component set TARGET COMPONENT PROPERTY VALUE +``` + +### Search Methods + +```bash +--method by_name # default for gameobject find +--method by_id +--method by_path +--method by_tag +--method by_component +``` + +### Global Options Position + +```bash +unity-mcp [GLOBAL_OPTIONS] command subcommand [ARGS] [OPTIONS] +# ^^^^^^^^^^^^^^^^ +# Must come BEFORE command! +``` + +--- + +## Debugging Tips + +1. **Always check `--help`** for any command: + + ```bash + unity-mcp gameobject --help + unity-mcp gameobject modify --help + ``` + +2. **Use verbose mode** to see what's happening: + + ```bash + unity-mcp -v scene hierarchy + ``` + +3. **Use JSON output** for programmatic parsing: + + ```bash + unity-mcp -f json gameobject find "Player" | jq '.result' + ``` + +4. **Check connection first**: + + ```bash + unity-mcp status + ``` + +5. **When in doubt about properties**, use info commands: + + ```bash + unity-mcp material info "Assets/Materials/Mat.mat" + unity-mcp asset info "Assets/Prefabs/Player.prefab" + ``` diff --git a/Server/src/cli/__init__.py b/Server/src/cli/__init__.py new file mode 100644 index 000000000..6252f494c --- /dev/null +++ b/Server/src/cli/__init__.py @@ -0,0 +1,3 @@ +"""Unity MCP Command Line Interface.""" + +__version__ = "1.0.0" diff --git a/Server/src/cli/commands/__init__.py b/Server/src/cli/commands/__init__.py new file mode 100644 index 000000000..0ea06247f --- /dev/null +++ b/Server/src/cli/commands/__init__.py @@ -0,0 +1,3 @@ +"""CLI command modules.""" + +# Commands will be registered in main.py diff --git a/Server/src/cli/commands/animation.py b/Server/src/cli/commands/animation.py new file mode 100644 index 000000000..5a9182c28 --- /dev/null +++ b/Server/src/cli/commands/animation.py @@ -0,0 +1,85 @@ +"""Animation CLI commands - placeholder for future implementation.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_info +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def animation(): + """Animation operations - control Animator, play animations.""" + pass + + +@animation.command("play") +@click.argument("target") +@click.argument("state_name") +@click.option( + "--layer", "-l", + default=0, + type=int, + help="Animator layer." +) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_id"]), + default=None, + help="How to find the target." +) +def play(target: str, state_name: str, layer: int, search_method: Optional[str]): + """Play an animation state on a target's Animator. + + \b + Examples: + unity-mcp animation play "Player" "Walk" + unity-mcp animation play "Enemy" "Attack" --layer 1 + """ + config = get_config() + + # Set Animator parameter to trigger state + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": "Animator", + "property": "Play", + "value": state_name, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@animation.command("set-parameter") +@click.argument("target") +@click.argument("param_name") +@click.argument("value") +@click.option( + "--type", "-t", + "param_type", + type=click.Choice(["float", "int", "bool", "trigger"]), + default="float", + help="Parameter type." +) +def set_parameter(target: str, param_name: str, value: str, param_type: str): + """Set an Animator parameter. + + \b + Examples: + unity-mcp animation set-parameter "Player" "Speed" 5.0 + unity-mcp animation set-parameter "Player" "IsRunning" true --type bool + unity-mcp animation set-parameter "Player" "Jump" "" --type trigger + """ + config = get_config() + print_info("Animation parameter command - requires custom Unity implementation") + click.echo(f"Would set {param_name}={value} ({param_type}) on {target}") diff --git a/Server/src/cli/commands/asset.py b/Server/src/cli/commands/asset.py new file mode 100644 index 000000000..b807bc4ca --- /dev/null +++ b/Server/src/cli/commands/asset.py @@ -0,0 +1,307 @@ +"""Asset CLI commands.""" + +import sys +import json +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def asset(): + """Asset operations - search, import, create, delete assets.""" + pass + + +@asset.command("search") +@click.argument("pattern", default="*") +@click.option( + "--path", "-p", + default="Assets", + help="Folder path to search in." +) +@click.option( + "--type", "-t", + "filter_type", + default=None, + help="Filter by asset type (e.g., Material, Prefab, MonoScript)." +) +@click.option( + "--limit", "-l", + default=25, + type=int, + help="Maximum results per page." +) +@click.option( + "--page", + default=1, + type=int, + help="Page number (1-based)." +) +def search(pattern: str, path: str, filter_type: Optional[str], limit: int, page: int): + """Search for assets. + + \b + Examples: + unity-mcp asset search "*.prefab" + unity-mcp asset search "Player*" --path "Assets/Characters" + unity-mcp asset search "*" --type Material + unity-mcp asset search "t:MonoScript" --path "Assets/Scripts" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "search", + "path": path, + "searchPattern": pattern, + "pageSize": limit, + "pageNumber": page, + } + + if filter_type: + params["filterType"] = filter_type + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("info") +@click.argument("path") +@click.option( + "--preview", + is_flag=True, + help="Generate preview thumbnail (may be large)." +) +def info(path: str, preview: bool): + """Get detailed information about an asset. + + \b + Examples: + unity-mcp asset info "Assets/Materials/Red.mat" + unity-mcp asset info "Assets/Prefabs/Player.prefab" --preview + """ + config = get_config() + + params: dict[str, Any] = { + "action": "get_info", + "path": path, + "generatePreview": preview, + } + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("create") +@click.argument("path") +@click.argument("asset_type") +@click.option( + "--properties", "-p", + default=None, + help='Initial properties as JSON.' +) +def create(path: str, asset_type: str, properties: Optional[str]): + """Create a new asset. + + \b + Examples: + unity-mcp asset create "Assets/Materials/Blue.mat" Material + unity-mcp asset create "Assets/NewFolder" Folder + unity-mcp asset create "Assets/Materials/Custom.mat" Material --properties '{"color": [0,0,1,1]}' + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create", + "path": path, + "assetType": asset_type, + } + + if properties: + try: + params["properties"] = json.loads(properties) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for properties: {e}") + sys.exit(1) + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created {asset_type}: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("delete") +@click.argument("path") +@click.option( + "--force", "-f", + is_flag=True, + help="Skip confirmation prompt." +) +def delete(path: str, force: bool): + """Delete an asset. + + \b + Examples: + unity-mcp asset delete "Assets/OldMaterial.mat" + unity-mcp asset delete "Assets/Unused" --force + """ + config = get_config() + + if not force: + click.confirm(f"Delete asset '{path}'?", abort=True) + + try: + result = run_command("manage_asset", {"action": "delete", "path": path}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Deleted: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("duplicate") +@click.argument("source") +@click.argument("destination") +def duplicate(source: str, destination: str): + """Duplicate an asset. + + \b + Examples: + unity-mcp asset duplicate "Assets/Materials/Red.mat" "Assets/Materials/RedCopy.mat" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "duplicate", + "path": source, + "destination": destination, + } + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Duplicated to: {destination}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("move") +@click.argument("source") +@click.argument("destination") +def move(source: str, destination: str): + """Move an asset to a new location. + + \b + Examples: + unity-mcp asset move "Assets/Old/Material.mat" "Assets/New/Material.mat" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "move", + "path": source, + "destination": destination, + } + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Moved to: {destination}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("rename") +@click.argument("path") +@click.argument("new_name") +def rename(path: str, new_name: str): + """Rename an asset. + + \b + Examples: + unity-mcp asset rename "Assets/Materials/Old.mat" "New.mat" + """ + config = get_config() + + # Construct destination path + import os + dir_path = os.path.dirname(path) + destination = os.path.join(dir_path, new_name).replace("\\", "/") + + params: dict[str, Any] = { + "action": "rename", + "path": path, + "destination": destination, + } + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Renamed to: {new_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("import") +@click.argument("path") +def import_asset(path: str): + """Import/reimport an asset. + + \b + Examples: + unity-mcp asset import "Assets/Textures/NewTexture.png" + """ + config = get_config() + + try: + result = run_command("manage_asset", {"action": "import", "path": path}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Imported: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("mkdir") +@click.argument("path") +def mkdir(path: str): + """Create a folder. + + \b + Examples: + unity-mcp asset mkdir "Assets/NewFolder" + unity-mcp asset mkdir "Assets/Levels/Chapter1" + """ + config = get_config() + + try: + result = run_command("manage_asset", {"action": "create_folder", "path": path}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created folder: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/audio.py b/Server/src/cli/commands/audio.py new file mode 100644 index 000000000..af0d5917e --- /dev/null +++ b/Server/src/cli/commands/audio.py @@ -0,0 +1,130 @@ +"""Audio CLI commands - placeholder for future implementation.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_info +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def audio(): + """Audio operations - AudioSource control, audio settings.""" + pass + + +@audio.command("play") +@click.argument("target") +@click.option( + "--clip", "-c", + default=None, + help="Audio clip path to play." +) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_id"]), + default=None, + help="How to find the target." +) +def play(target: str, clip: Optional[str], search_method: Optional[str]): + """Play audio on a target's AudioSource. + + \b + Examples: + unity-mcp audio play "MusicPlayer" + unity-mcp audio play "SFXSource" --clip "Assets/Audio/explosion.wav" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": "AudioSource", + "property": "Play", + "value": True, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@audio.command("stop") +@click.argument("target") +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_id"]), + default=None, + help="How to find the target." +) +def stop(target: str, search_method: Optional[str]): + """Stop audio on a target's AudioSource. + + \b + Examples: + unity-mcp audio stop "MusicPlayer" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": "AudioSource", + "property": "Stop", + "value": True, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@audio.command("volume") +@click.argument("target") +@click.argument("level", type=float) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_id"]), + default=None, + help="How to find the target." +) +def volume(target: str, level: float, search_method: Optional[str]): + """Set audio volume on a target's AudioSource. + + \b + Examples: + unity-mcp audio volume "MusicPlayer" 0.5 + """ + config = get_config() + + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": "AudioSource", + "property": "volume", + "value": level, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/code.py b/Server/src/cli/commands/code.py new file mode 100644 index 000000000..1b00596a8 --- /dev/null +++ b/Server/src/cli/commands/code.py @@ -0,0 +1,71 @@ +"""Code CLI commands - search and read source code.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_info +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def code(): + """Code operations - read source files.""" + pass + + +@code.command("read") +@click.argument("path") +@click.option( + "--start-line", "-s", + default=None, + type=int, + help="Starting line number (1-based)." +) +@click.option( + "--line-count", "-n", + default=None, + type=int, + help="Number of lines to read." +) +def read(path: str, start_line: Optional[int], line_count: Optional[int]): + """Read a source file. + + \b + Examples: + unity-mcp code read "Assets/Scripts/Player.cs" + unity-mcp code read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20 + """ + config = get_config() + + # Extract name and directory from path + parts = path.replace("\\", "/").split("/") + filename = parts[-1].replace(".cs", "") + directory = "/".join(parts[:-1]) or "Assets" + + params: dict[str, Any] = { + "action": "read", + "name": filename, + "path": directory, + } + + if start_line: + params["startLine"] = start_line + if line_count: + params["lineCount"] = line_count + + try: + result = run_command("manage_script", params, config) + # For read, output content directly if available + if result.get("success") and result.get("data"): + data = result.get("data", {}) + if isinstance(data, dict) and "contents" in data: + click.echo(data["contents"]) + else: + click.echo(format_output(result, config.format)) + else: + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/component.py b/Server/src/cli/commands/component.py new file mode 100644 index 000000000..13e53940b --- /dev/null +++ b/Server/src/cli/commands/component.py @@ -0,0 +1,212 @@ +"""Component CLI commands.""" + +import sys +import json +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def component(): + """Component operations - add, remove, modify components on GameObjects.""" + pass + + +@component.command("add") +@click.argument("target") +@click.argument("component_type") +@click.option( + "--search-method", + type=click.Choice(["by_id", "by_name", "by_path"]), + default=None, + help="How to find the target GameObject." +) +@click.option( + "--properties", "-p", + default=None, + help='Initial properties as JSON (e.g., \'{"mass": 5.0}\').' +) +def add(target: str, component_type: str, search_method: Optional[str], properties: Optional[str]): + """Add a component to a GameObject. + + \b + Examples: + unity-mcp component add "Player" Rigidbody + unity-mcp component add "-81840" BoxCollider --search-method by_id + unity-mcp component add "Enemy" Rigidbody --properties '{"mass": 5.0, "useGravity": true}' + """ + config = get_config() + + params: dict[str, Any] = { + "action": "add", + "target": target, + "componentType": component_type, + } + + if search_method: + params["searchMethod"] = search_method + if properties: + try: + params["properties"] = json.loads(properties) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for properties: {e}") + sys.exit(1) + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Added {component_type} to '{target}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@component.command("remove") +@click.argument("target") +@click.argument("component_type") +@click.option( + "--search-method", + type=click.Choice(["by_id", "by_name", "by_path"]), + default=None, + help="How to find the target GameObject." +) +@click.option( + "--force", "-f", + is_flag=True, + help="Skip confirmation prompt." +) +def remove(target: str, component_type: str, search_method: Optional[str], force: bool): + """Remove a component from a GameObject. + + \b + Examples: + unity-mcp component remove "Player" Rigidbody + unity-mcp component remove "-81840" BoxCollider --search-method by_id --force + """ + config = get_config() + + if not force: + click.confirm(f"Remove {component_type} from '{target}'?", abort=True) + + params: dict[str, Any] = { + "action": "remove", + "target": target, + "componentType": component_type, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Removed {component_type} from '{target}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@component.command("set") +@click.argument("target") +@click.argument("component_type") +@click.argument("property_name") +@click.argument("value") +@click.option( + "--search-method", + type=click.Choice(["by_id", "by_name", "by_path"]), + default=None, + help="How to find the target GameObject." +) +def set_property(target: str, component_type: str, property_name: str, value: str, search_method: Optional[str]): + """Set a single property on a component. + + \b + Examples: + unity-mcp component set "Player" Rigidbody mass 5.0 + unity-mcp component set "Enemy" Transform position "[0, 5, 0]" + unity-mcp component set "-81840" Light intensity 2.5 --search-method by_id + """ + config = get_config() + + # Try to parse value as JSON for complex types + try: + parsed_value = json.loads(value) + except json.JSONDecodeError: + # Keep as string if not valid JSON + parsed_value = value + + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": component_type, + "property": property_name, + "value": parsed_value, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set {component_type}.{property_name} = {value}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@component.command("modify") +@click.argument("target") +@click.argument("component_type") +@click.option( + "--properties", "-p", + required=True, + help='Properties to set as JSON (e.g., \'{"mass": 5.0, "useGravity": false}\').' +) +@click.option( + "--search-method", + type=click.Choice(["by_id", "by_name", "by_path"]), + default=None, + help="How to find the target GameObject." +) +def modify(target: str, component_type: str, properties: str, search_method: Optional[str]): + """Set multiple properties on a component at once. + + \b + Examples: + unity-mcp component modify "Player" Rigidbody --properties '{"mass": 5.0, "useGravity": false}' + unity-mcp component modify "Light" Light --properties '{"intensity": 2.0, "color": [1, 0, 0, 1]}' + """ + config = get_config() + + try: + props_dict = json.loads(properties) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for properties: {e}") + sys.exit(1) + + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": component_type, + "properties": props_dict, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Modified {component_type} on '{target}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py new file mode 100644 index 000000000..ea35f3dc5 --- /dev/null +++ b/Server/src/cli/commands/editor.py @@ -0,0 +1,299 @@ +"""Editor CLI commands.""" + +import sys +import click +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 + + +@click.group() +def editor(): + """Editor operations - play mode, console, tags, layers.""" + pass + + +@editor.command("play") +def play(): + """Enter play mode.""" + config = get_config() + + try: + result = run_command("manage_editor", {"action": "play"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Entered play mode") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("pause") +def pause(): + """Pause play mode.""" + config = get_config() + + try: + result = run_command("manage_editor", {"action": "pause"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Paused play mode") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("stop") +def stop(): + """Stop play mode.""" + config = get_config() + + try: + result = run_command("manage_editor", {"action": "stop"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Stopped play mode") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("console") +@click.option( + "--type", "-t", + "log_types", + multiple=True, + type=click.Choice(["error", "warning", "log", "all"]), + default=["error", "warning", "log"], + help="Message types to retrieve." +) +@click.option( + "--count", "-n", + default=10, + type=int, + help="Number of messages to retrieve." +) +@click.option( + "--filter", "-f", + "filter_text", + default=None, + help="Filter messages containing this text." +) +@click.option( + "--stacktrace", "-s", + is_flag=True, + help="Include stack traces." +) +@click.option( + "--clear", + is_flag=True, + help="Clear the console instead of reading." +) +def console(log_types: tuple, count: int, filter_text: Optional[str], stacktrace: bool, clear: bool): + """Read or clear the Unity console. + + \b + Examples: + unity-mcp editor console + unity-mcp editor console --type error --count 20 + unity-mcp editor console --filter "NullReference" --stacktrace + unity-mcp editor console --clear + """ + config = get_config() + + if clear: + try: + result = run_command("read_console", {"action": "clear"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Console cleared") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + return + + params: dict[str, Any] = { + "action": "get", + "types": list(log_types), + "count": count, + "include_stacktrace": stacktrace, + } + + if filter_text: + params["filter_text"] = filter_text + + try: + result = run_command("read_console", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("add-tag") +@click.argument("tag_name") +def add_tag(tag_name: str): + """Add a new tag. + + \b + Examples: + unity-mcp editor add-tag "Enemy" + unity-mcp editor add-tag "Collectible" + """ + config = get_config() + + try: + result = run_command("manage_editor", {"action": "add_tag", "tagName": tag_name}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Added tag: {tag_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("remove-tag") +@click.argument("tag_name") +def remove_tag(tag_name: str): + """Remove a tag. + + \b + Examples: + unity-mcp editor remove-tag "OldTag" + """ + config = get_config() + + try: + result = run_command("manage_editor", {"action": "remove_tag", "tagName": tag_name}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Removed tag: {tag_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("add-layer") +@click.argument("layer_name") +def add_layer(layer_name: str): + """Add a new layer. + + \b + Examples: + unity-mcp editor add-layer "Interactable" + """ + config = get_config() + + try: + result = run_command("manage_editor", {"action": "add_layer", "layerName": layer_name}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Added layer: {layer_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("remove-layer") +@click.argument("layer_name") +def remove_layer(layer_name: str): + """Remove a layer. + + \b + Examples: + unity-mcp editor remove-layer "OldLayer" + """ + config = get_config() + + try: + result = run_command("manage_editor", {"action": "remove_layer", "layerName": layer_name}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Removed layer: {layer_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("tool") +@click.argument("tool_name") +def set_tool(tool_name: str): + """Set the active editor tool. + + \b + Examples: + unity-mcp editor tool "Move" + unity-mcp editor tool "Rotate" + unity-mcp editor tool "Scale" + """ + config = get_config() + + try: + result = run_command("manage_editor", {"action": "set_active_tool", "toolName": tool_name}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set active tool: {tool_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("menu") +@click.argument("menu_path") +def execute_menu(menu_path: str): + """Execute a menu item. + + \b + Examples: + unity-mcp editor menu "File/Save" + unity-mcp editor menu "Edit/Undo" + unity-mcp editor menu "GameObject/Create Empty" + """ + config = get_config() + + try: + result = run_command("execute_menu_item", {"menu_path": menu_path}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Executed: {menu_path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("tests") +@click.option( + "--mode", "-m", + type=click.Choice(["EditMode", "PlayMode"]), + default="EditMode", + help="Test mode to run." +) +@click.option( + "--timeout", "-t", + default=None, + type=int, + help="Timeout in seconds." +) +def run_tests(mode: str, timeout: Optional[int]): + """Run Unity tests. + + \b + Examples: + unity-mcp editor tests + unity-mcp editor tests --mode PlayMode + unity-mcp editor tests --timeout 60 + """ + config = get_config() + + params: dict[str, Any] = {"mode": mode} + if timeout: + params["timeout_seconds"] = timeout + + try: + result = run_command("run_tests", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/gameobject.py b/Server/src/cli/commands/gameobject.py new file mode 100644 index 000000000..1176c8149 --- /dev/null +++ b/Server/src/cli/commands/gameobject.py @@ -0,0 +1,497 @@ +"""GameObject CLI commands.""" + +import sys +import json +import click +from typing import Optional, Tuple, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def gameobject(): + """GameObject operations - create, find, modify, delete GameObjects.""" + pass + + +@gameobject.command("find") +@click.argument("search_term") +@click.option( + "--method", "-m", + type=click.Choice(["by_name", "by_tag", "by_layer", "by_component", "by_path", "by_id"]), + default="by_name", + help="Search method." +) +@click.option( + "--include-inactive", "-i", + is_flag=True, + help="Include inactive GameObjects." +) +@click.option( + "--limit", "-l", + default=50, + type=int, + help="Maximum results to return." +) +@click.option( + "--cursor", "-c", + default=0, + type=int, + help="Pagination cursor (offset)." +) +def find(search_term: str, method: str, include_inactive: bool, limit: int, cursor: int): + """Find GameObjects by search criteria. + + \b + Examples: + unity-mcp gameobject find "Player" + unity-mcp gameobject find "Enemy" --method by_tag + unity-mcp gameobject find "-81840" --method by_id + unity-mcp gameobject find "Rigidbody" --method by_component + unity-mcp gameobject find "/Canvas/Panel" --method by_path + """ + config = get_config() + + try: + result = run_command("find_gameobjects", { + "searchMethod": method, + "searchTerm": search_term, + "includeInactive": include_inactive, + "pageSize": limit, + "cursor": cursor, + }, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@gameobject.command("create") +@click.argument("name") +@click.option( + "--primitive", "-p", + type=click.Choice(["Cube", "Sphere", "Cylinder", "Plane", "Capsule", "Quad"]), + help="Create a primitive type." +) +@click.option( + "--position", "-pos", + nargs=3, + type=float, + default=None, + help="Position as X Y Z." +) +@click.option( + "--rotation", "-rot", + nargs=3, + type=float, + default=None, + help="Rotation as X Y Z (euler angles)." +) +@click.option( + "--scale", "-s", + nargs=3, + type=float, + default=None, + help="Scale as X Y Z." +) +@click.option( + "--parent", + default=None, + help="Parent GameObject name or path." +) +@click.option( + "--tag", "-t", + default=None, + help="Tag to assign." +) +@click.option( + "--layer", + default=None, + help="Layer to assign." +) +@click.option( + "--components", + default=None, + help="Comma-separated list of components to add." +) +@click.option( + "--save-prefab", + is_flag=True, + help="Save as prefab after creation." +) +@click.option( + "--prefab-path", + default=None, + help="Path for prefab (e.g., Assets/Prefabs/MyPrefab.prefab)." +) +def create( + name: str, + primitive: Optional[str], + position: Optional[Tuple[float, float, float]], + rotation: Optional[Tuple[float, float, float]], + scale: Optional[Tuple[float, float, float]], + parent: Optional[str], + tag: Optional[str], + layer: Optional[str], + components: Optional[str], + save_prefab: bool, + prefab_path: Optional[str], +): + """Create a new GameObject. + + \b + Examples: + unity-mcp gameobject create "MyCube" --primitive Cube + unity-mcp gameobject create "Player" --position 0 1 0 + unity-mcp gameobject create "Enemy" --primitive Sphere --tag Enemy + unity-mcp gameobject create "Child" --parent "ParentObject" + unity-mcp gameobject create "Item" --components "Rigidbody,BoxCollider" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create", + "name": name, + } + + if primitive: + params["primitiveType"] = primitive + if position: + params["position"] = list(position) + if rotation: + params["rotation"] = list(rotation) + if scale: + params["scale"] = list(scale) + if parent: + params["parent"] = parent + if tag: + params["tag"] = tag + if layer: + params["layer"] = layer + if save_prefab: + params["saveAsPrefab"] = True + if prefab_path: + params["prefabPath"] = prefab_path + + try: + result = run_command("manage_gameobject", params, config) + + # Add components separately since componentsToAdd doesn't work + if components and (result.get("success") or result.get("data") or result.get("result")): + component_list = [c.strip() for c in components.split(",")] + for component in component_list: + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": component, + }, config) + + click.echo(format_output(result, config.format)) + if result.get("success") or result.get("result"): + print_success(f"Created GameObject '{name}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@gameobject.command("modify") +@click.argument("target") +@click.option( + "--name", "-n", + default=None, + help="New name for the GameObject." +) +@click.option( + "--position", "-pos", + nargs=3, + type=float, + default=None, + help="New position as X Y Z." +) +@click.option( + "--rotation", "-rot", + nargs=3, + type=float, + default=None, + help="New rotation as X Y Z (euler angles)." +) +@click.option( + "--scale", "-s", + nargs=3, + type=float, + default=None, + help="New scale as X Y Z." +) +@click.option( + "--parent", + default=None, + help="New parent GameObject." +) +@click.option( + "--tag", "-t", + default=None, + help="New tag." +) +@click.option( + "--layer", + default=None, + help="New layer." +) +@click.option( + "--active/--inactive", + default=None, + help="Set active state." +) +@click.option( + "--add-components", + default=None, + help="Comma-separated list of components to add." +) +@click.option( + "--remove-components", + default=None, + help="Comma-separated list of components to remove." +) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_id"]), + default=None, + help="How to find the target GameObject." +) +def modify( + target: str, + name: Optional[str], + position: Optional[Tuple[float, float, float]], + rotation: Optional[Tuple[float, float, float]], + scale: Optional[Tuple[float, float, float]], + parent: Optional[str], + tag: Optional[str], + layer: Optional[str], + active: Optional[bool], + add_components: Optional[str], + remove_components: Optional[str], + search_method: Optional[str], +): + """Modify an existing GameObject. + + TARGET can be a name, path, instance ID, or tag depending on --search-method. + + \b + Examples: + unity-mcp gameobject modify "Player" --position 0 5 0 + unity-mcp gameobject modify "Enemy" --name "Boss" --tag "Boss" + unity-mcp gameobject modify "-81840" --search-method by_id --active + unity-mcp gameobject modify "/Canvas/Panel" --search-method by_path --inactive + unity-mcp gameobject modify "Cube" --add-components "Rigidbody,BoxCollider" + """ + config = get_config() + + params = { + "action": "modify", + "target": target, + } + + if name: + params["name"] = name + if position: + params["position"] = list(position) + if rotation: + params["rotation"] = list(rotation) + if scale: + params["scale"] = list(scale) + if parent: + params["parent"] = parent + if tag: + params["tag"] = tag + if layer: + params["layer"] = layer + if active is not None: + params["setActive"] = active + if add_components: + params["componentsToAdd"] = [c.strip() for c in add_components.split(",")] + if remove_components: + params["componentsToRemove"] = [c.strip() for c in remove_components.split(",")] + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_gameobject", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@gameobject.command("delete") +@click.argument("target") +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_id"]), + default=None, + help="How to find the target GameObject." +) +@click.option( + "--force", "-f", + is_flag=True, + help="Skip confirmation prompt." +) +def delete(target: str, search_method: Optional[str], force: bool): + """Delete a GameObject. + + \b + Examples: + unity-mcp gameobject delete "OldObject" + unity-mcp gameobject delete "-81840" --search-method by_id + unity-mcp gameobject delete "TempObjects" --search-method by_tag --force + """ + config = get_config() + + if not force: + click.confirm(f"Delete GameObject '{target}'?", abort=True) + + params = { + "action": "delete", + "target": target, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_gameobject", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Deleted GameObject '{target}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@gameobject.command("duplicate") +@click.argument("target") +@click.option( + "--name", "-n", + default=None, + help="Name for the duplicate (default: OriginalName_Copy)." +) +@click.option( + "--offset", + nargs=3, + type=float, + default=None, + help="Position offset from original as X Y Z." +) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_id"]), + default=None, + help="How to find the target GameObject." +) +def duplicate( + target: str, + name: Optional[str], + offset: Optional[Tuple[float, float, float]], + search_method: Optional[str], +): + """Duplicate a GameObject. + + \b + Examples: + unity-mcp gameobject duplicate "Player" + unity-mcp gameobject duplicate "Enemy" --name "Enemy2" --offset 5 0 0 + unity-mcp gameobject duplicate "-81840" --search-method by_id + """ + config = get_config() + + params = { + "action": "duplicate", + "target": target, + } + + if name: + params["new_name"] = name + if offset: + params["offset"] = list(offset) + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_gameobject", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Duplicated GameObject '{target}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@gameobject.command("move") +@click.argument("target") +@click.option( + "--reference", "-r", + required=True, + help="Reference object for relative movement." +) +@click.option( + "--direction", "-d", + type=click.Choice(["left", "right", "up", "down", "forward", "back", "front", "backward", "behind"]), + required=True, + help="Direction to move." +) +@click.option( + "--distance", + type=float, + default=1.0, + help="Distance to move (default: 1.0)." +) +@click.option( + "--local", + is_flag=True, + help="Use reference object's local space instead of world space." +) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_id"]), + default=None, + help="How to find the target GameObject." +) +def move( + target: str, + reference: str, + direction: str, + distance: float, + local: bool, + search_method: Optional[str], +): + """Move a GameObject relative to another object. + + \b + Examples: + unity-mcp gameobject move "Chair" --reference "Table" --direction right --distance 2 + unity-mcp gameobject move "Light" --reference "Player" --direction up --distance 3 + unity-mcp gameobject move "NPC" --reference "Player" --direction forward --local + """ + config = get_config() + + params = { + "action": "move_relative", + "target": target, + "reference_object": reference, + "direction": direction, + "distance": distance, + "world_space": not local, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_gameobject", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Moved '{target}' {direction} of '{reference}' by {distance} units") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/lighting.py b/Server/src/cli/commands/lighting.py new file mode 100644 index 000000000..9b125093b --- /dev/null +++ b/Server/src/cli/commands/lighting.py @@ -0,0 +1,112 @@ +"""Lighting CLI commands.""" + +import sys +import click +from typing import Optional, Tuple + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def lighting(): + """Lighting operations - create, modify lights and lighting settings.""" + pass + + +@lighting.command("create") +@click.argument("name") +@click.option( + "--type", "-t", + "light_type", + type=click.Choice(["Directional", "Point", "Spot", "Area"]), + default="Point", + help="Type of light to create." +) +@click.option( + "--position", "-pos", + nargs=3, + type=float, + default=(0, 3, 0), + help="Position as X Y Z." +) +@click.option( + "--color", "-c", + nargs=3, + type=float, + default=None, + help="Color as R G B (0-1)." +) +@click.option( + "--intensity", "-i", + default=None, + type=float, + help="Light intensity." +) +def create(name: str, light_type: str, position: Tuple[float, float, float], color: Optional[Tuple[float, float, float]], intensity: Optional[float]): + """Create a new light. + + \b + Examples: + unity-mcp lighting create "MainLight" --type Directional + unity-mcp lighting create "PointLight1" --position 0 5 0 --intensity 2 + unity-mcp lighting create "RedLight" --type Spot --color 1 0 0 + """ + config = get_config() + + try: + # Step 1: Create empty GameObject with position + create_result = run_command("manage_gameobject", { + "action": "create", + "name": name, + "position": list(position), + }, config) + + if not (create_result.get("success") or create_result.get("data") or create_result.get("result")): + click.echo(format_output(create_result, config.format)) + return + + # Step 2: Add Light component using manage_components + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": "Light", + }, config) + + # Step 3: Set light type using manage_components set_property + run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Light", + "property": "type", + "value": light_type, + }, config) + + # Step 4: Set color if provided + if color: + run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Light", + "property": "color", + "value": {"r": color[0], "g": color[1], "b": color[2], "a": 1}, + }, config) + + # Step 5: Set intensity if provided + if intensity is not None: + run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Light", + "property": "intensity", + "value": intensity, + }, config) + + # Output the result + click.echo(format_output(create_result, config.format)) + print_success(f"Created {light_type} light: {name}") + + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/material.py b/Server/src/cli/commands/material.py new file mode 100644 index 000000000..4488c2fd1 --- /dev/null +++ b/Server/src/cli/commands/material.py @@ -0,0 +1,266 @@ +"""Material CLI commands.""" + +import sys +import json +import click +from typing import Optional, Any, Tuple + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def material(): + """Material operations - create, modify, assign materials.""" + pass + + +@material.command("info") +@click.argument("path") +def info(path: str): + """Get information about a material. + + \b + Examples: + unity-mcp material info "Assets/Materials/Red.mat" + """ + config = get_config() + + try: + result = run_command("manage_material", { + "action": "get_material_info", + "materialPath": path, + }, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@material.command("create") +@click.argument("path") +@click.option( + "--shader", "-s", + default="Standard", + help="Shader to use (default: Standard)." +) +@click.option( + "--properties", "-p", + default=None, + help='Initial properties as JSON.' +) +def create(path: str, shader: str, properties: Optional[str]): + """Create a new material. + + \b + Examples: + unity-mcp material create "Assets/Materials/NewMat.mat" + unity-mcp material create "Assets/Materials/Red.mat" --shader "Universal Render Pipeline/Lit" + unity-mcp material create "Assets/Materials/Blue.mat" --properties '{"_Color": [0,0,1,1]}' + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create", + "materialPath": path, + "shader": shader, + } + + if properties: + try: + params["properties"] = json.loads(properties) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for properties: {e}") + sys.exit(1) + + try: + result = run_command("manage_material", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created material: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@material.command("set-color") +@click.argument("path") +@click.argument("r", type=float) +@click.argument("g", type=float) +@click.argument("b", type=float) +@click.argument("a", type=float, default=1.0) +@click.option( + "--property", "-p", + default="_Color", + help="Color property name (default: _Color)." +) +def set_color(path: str, r: float, g: float, b: float, a: float, property: str): + """Set a material's color. + + \b + Examples: + unity-mcp material set-color "Assets/Materials/Red.mat" 1 0 0 + unity-mcp material set-color "Assets/Materials/Blue.mat" 0 0 1 0.5 + unity-mcp material set-color "Assets/Materials/Mat.mat" 1 1 0 --property "_BaseColor" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "set_material_color", + "materialPath": path, + "property": property, + "color": [r, g, b, a], + } + + try: + result = run_command("manage_material", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set color on: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@material.command("set-property") +@click.argument("path") +@click.argument("property_name") +@click.argument("value") +def set_property(path: str, property_name: str, value: str): + """Set a shader property on a material. + + \b + Examples: + unity-mcp material set-property "Assets/Materials/Mat.mat" _Metallic 0.5 + unity-mcp material set-property "Assets/Materials/Mat.mat" _Smoothness 0.8 + unity-mcp material set-property "Assets/Materials/Mat.mat" _MainTex "Assets/Textures/Tex.png" + """ + config = get_config() + + # Try to parse value as JSON for complex types + try: + parsed_value = json.loads(value) + except json.JSONDecodeError: + # Try to parse as number + try: + parsed_value = float(value) + except ValueError: + parsed_value = value + + params: dict[str, Any] = { + "action": "set_material_shader_property", + "materialPath": path, + "property": property_name, + "value": parsed_value, + } + + try: + result = run_command("manage_material", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set {property_name} on: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@material.command("assign") +@click.argument("material_path") +@click.argument("target") +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_layer", "by_component"]), + default=None, + help="How to find the target GameObject." +) +@click.option( + "--slot", "-s", + default=0, + type=int, + help="Material slot index (default: 0)." +) +@click.option( + "--mode", "-m", + type=click.Choice(["shared", "instance", "property_block"]), + default="shared", + help="Assignment mode." +) +def assign(material_path: str, target: str, search_method: Optional[str], slot: int, mode: str): + """Assign a material to a GameObject's renderer. + + \b + Examples: + unity-mcp material assign "Assets/Materials/Red.mat" "Cube" + unity-mcp material assign "Assets/Materials/Blue.mat" "Player" --mode instance + unity-mcp material assign "Assets/Materials/Mat.mat" "-81840" --search-method by_id --slot 1 + """ + config = get_config() + + params: dict[str, Any] = { + "action": "assign_material_to_renderer", + "materialPath": material_path, + "target": target, + "slot": slot, + "mode": mode, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_material", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Assigned material to: {target}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@material.command("set-renderer-color") +@click.argument("target") +@click.argument("r", type=float) +@click.argument("g", type=float) +@click.argument("b", type=float) +@click.argument("a", type=float, default=1.0) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_layer", "by_component"]), + default=None, + help="How to find the target GameObject." +) +@click.option( + "--mode", "-m", + type=click.Choice(["shared", "instance", "property_block"]), + default="property_block", + help="Modification mode." +) +def set_renderer_color(target: str, r: float, g: float, b: float, a: float, search_method: Optional[str], mode: str): + """Set a renderer's material color directly. + + \b + Examples: + unity-mcp material set-renderer-color "Cube" 1 0 0 + unity-mcp material set-renderer-color "Player" 0 1 0 --mode instance + """ + config = get_config() + + params: dict[str, Any] = { + "action": "set_renderer_color", + "target": target, + "color": [r, g, b, a], + "mode": mode, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_material", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set renderer color on: {target}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/prefab.py b/Server/src/cli/commands/prefab.py new file mode 100644 index 000000000..3191c11e9 --- /dev/null +++ b/Server/src/cli/commands/prefab.py @@ -0,0 +1,143 @@ +"""Prefab CLI commands.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def prefab(): + """Prefab operations - open, save, create prefabs.""" + pass + + +@prefab.command("open") +@click.argument("path") +@click.option( + "--mode", "-m", + default="InIsolation", + help="Prefab stage mode (InIsolation)." +) +def open_stage(path: str, mode: str): + """Open a prefab in the prefab stage for editing. + + \b + Examples: + unity-mcp prefab open "Assets/Prefabs/Player.prefab" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "open_stage", + "prefabPath": path, + "mode": mode, + } + + try: + result = run_command("manage_prefabs", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Opened prefab: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@prefab.command("close") +@click.option( + "--save", "-s", + is_flag=True, + help="Save the prefab before closing." +) +def close_stage(save: bool): + """Close the current prefab stage. + + \b + Examples: + unity-mcp prefab close + unity-mcp prefab close --save + """ + config = get_config() + + params: dict[str, Any] = { + "action": "close_stage", + } + if save: + params["saveBeforeClose"] = True + + try: + result = run_command("manage_prefabs", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Closed prefab stage") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@prefab.command("save") +def save_stage(): + """Save the currently open prefab stage. + + \b + Examples: + unity-mcp prefab save + """ + config = get_config() + + try: + result = run_command("manage_prefabs", {"action": "save_open_stage"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Saved prefab") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@prefab.command("create") +@click.argument("target") +@click.argument("path") +@click.option( + "--overwrite", + is_flag=True, + help="Overwrite existing prefab at path." +) +@click.option( + "--include-inactive", + is_flag=True, + help="Include inactive objects when finding target." +) +def create(target: str, path: str, overwrite: bool, include_inactive: bool): + """Create a prefab from a scene GameObject. + + \b + Examples: + unity-mcp prefab create "Player" "Assets/Prefabs/Player.prefab" + unity-mcp prefab create "Enemy" "Assets/Prefabs/Enemy.prefab" --overwrite + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create_from_gameobject", + "target": target, + "prefabPath": path, + } + + if overwrite: + params["allowOverwrite"] = True + if include_inactive: + params["searchInactive"] = True + + try: + result = run_command("manage_prefabs", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created prefab: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/scene.py b/Server/src/cli/commands/scene.py new file mode 100644 index 000000000..a04b20446 --- /dev/null +++ b/Server/src/cli/commands/scene.py @@ -0,0 +1,254 @@ +"""Scene CLI commands.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def scene(): + """Scene operations - hierarchy, load, save, create scenes.""" + pass + + +@scene.command("hierarchy") +@click.option( + "--parent", + default=None, + help="Parent GameObject to list children of (name, path, or instance ID)." +) +@click.option( + "--max-depth", "-d", + default=None, + type=int, + help="Maximum depth to traverse." +) +@click.option( + "--include-transform", "-t", + is_flag=True, + help="Include transform data for each node." +) +@click.option( + "--limit", "-l", + default=50, + type=int, + help="Maximum nodes to return." +) +@click.option( + "--cursor", "-c", + default=0, + type=int, + help="Pagination cursor." +) +def hierarchy( + parent: Optional[str], + max_depth: Optional[int], + include_transform: bool, + limit: int, + cursor: int, +): + """Get the scene hierarchy. + + \b + Examples: + unity-mcp scene hierarchy + unity-mcp scene hierarchy --max-depth 3 + unity-mcp scene hierarchy --parent "Canvas" --include-transform + unity-mcp scene hierarchy --format json + """ + config = get_config() + + params: dict[str, Any] = { + "action": "get_hierarchy", + "pageSize": limit, + "cursor": cursor, + } + + if parent: + params["parent"] = parent + if max_depth is not None: + params["maxDepth"] = max_depth + if include_transform: + params["includeTransform"] = True + + try: + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("active") +def active(): + """Get information about the active scene.""" + config = get_config() + + try: + result = run_command("manage_scene", {"action": "get_active"}, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("load") +@click.argument("scene") +@click.option( + "--by-index", "-i", + is_flag=True, + help="Load by build index instead of path/name." +) +def load(scene: str, by_index: bool): + """Load a scene. + + \b + Examples: + unity-mcp scene load "Assets/Scenes/Main.unity" + unity-mcp scene load "MainScene" + unity-mcp scene load 0 --by-index + """ + config = get_config() + + params: dict[str, Any] = {"action": "load"} + + if by_index: + try: + params["buildIndex"] = int(scene) + except ValueError: + print_error(f"Invalid build index: {scene}") + sys.exit(1) + else: + if scene.endswith(".unity"): + params["path"] = scene + else: + params["name"] = scene + + try: + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Loaded scene: {scene}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("save") +@click.option( + "--path", + default=None, + help="Path to save the scene to (for new scenes)." +) +def save(path: Optional[str]): + """Save the current scene. + + \b + Examples: + unity-mcp scene save + unity-mcp scene save --path "Assets/Scenes/NewScene.unity" + """ + config = get_config() + + params: dict[str, Any] = {"action": "save"} + if path: + params["path"] = path + + try: + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Scene saved") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("create") +@click.argument("name") +@click.option( + "--path", + default=None, + help="Path to create the scene at." +) +def create(name: str, path: Optional[str]): + """Create a new scene. + + \b + Examples: + unity-mcp scene create "NewLevel" + unity-mcp scene create "TestScene" --path "Assets/Scenes/Test" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create", + "name": name, + } + if path: + params["path"] = path + + try: + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created scene: {name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("build-settings") +def build_settings(): + """Get scenes in build settings.""" + config = get_config() + + try: + result = run_command("manage_scene", {"action": "get_build_settings"}, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("screenshot") +@click.option( + "--filename", "-f", + default=None, + help="Output filename (default: timestamp)." +) +@click.option( + "--supersize", "-s", + default=1, + type=int, + help="Supersize multiplier (1-4)." +) +def screenshot(filename: Optional[str], supersize: int): + """Capture a screenshot of the scene. + + \b + Examples: + unity-mcp scene screenshot + unity-mcp scene screenshot --filename "level_preview" + unity-mcp scene screenshot --supersize 2 + """ + config = get_config() + + params: dict[str, Any] = {"action": "screenshot"} + if filename: + params["fileName"] = filename + if supersize > 1: + params["superSize"] = supersize + + try: + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Screenshot captured") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/script.py b/Server/src/cli/commands/script.py new file mode 100644 index 000000000..69f2dfc89 --- /dev/null +++ b/Server/src/cli/commands/script.py @@ -0,0 +1,229 @@ +"""Script CLI commands.""" + +import sys +import json +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def script(): + """Script operations - create, read, edit C# scripts.""" + pass + + +@script.command("create") +@click.argument("name") +@click.option( + "--path", "-p", + default="Assets/Scripts", + help="Directory to create the script in." +) +@click.option( + "--type", "-t", + "script_type", + type=click.Choice(["MonoBehaviour", "ScriptableObject", "Editor", "EditorWindow", "Plain"]), + default="MonoBehaviour", + help="Type of script to create." +) +@click.option( + "--namespace", "-n", + default=None, + help="Namespace for the script." +) +@click.option( + "--contents", "-c", + default=None, + help="Full script contents (overrides template)." +) +def create(name: str, path: str, script_type: str, namespace: Optional[str], contents: Optional[str]): + """Create a new C# script. + + \b + Examples: + unity-mcp script create "PlayerController" + unity-mcp script create "GameManager" --path "Assets/Scripts/Managers" + unity-mcp script create "EnemyData" --type ScriptableObject + unity-mcp script create "CustomEditor" --type Editor --namespace "MyGame.Editor" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create", + "name": name, + "path": path, + "scriptType": script_type, + } + + if namespace: + params["namespace"] = namespace + if contents: + params["contents"] = contents + + try: + result = run_command("manage_script", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created script: {name}.cs") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@script.command("read") +@click.argument("path") +@click.option( + "--start-line", "-s", + default=None, + type=int, + help="Starting line number (1-based)." +) +@click.option( + "--line-count", "-n", + default=None, + type=int, + help="Number of lines to read." +) +def read(path: str, start_line: Optional[int], line_count: Optional[int]): + """Read a C# script file. + + \b + Examples: + unity-mcp script read "Assets/Scripts/Player.cs" + unity-mcp script read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20 + """ + config = get_config() + + params: dict[str, Any] = { + "action": "read", + "name": path.split("/")[-1].replace(".cs", ""), + "path": "/".join(path.split("/")[:-1]) or "Assets", + } + + if start_line: + params["startLine"] = start_line + if line_count: + params["lineCount"] = line_count + + try: + result = run_command("manage_script", params, config) + # For read, just output the content directly + if result.get("success") and result.get("data"): + data = result.get("data", {}) + if isinstance(data, dict) and "content" in data: + click.echo(data["content"]) + else: + click.echo(format_output(result, config.format)) + else: + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@script.command("delete") +@click.argument("path") +@click.option( + "--force", "-f", + is_flag=True, + help="Skip confirmation prompt." +) +def delete(path: str, force: bool): + """Delete a C# script. + + \b + Examples: + unity-mcp script delete "Assets/Scripts/OldScript.cs" + """ + config = get_config() + + if not force: + click.confirm(f"Delete script '{path}'?", abort=True) + + params: dict[str, Any] = { + "action": "delete", + "name": path.split("/")[-1].replace(".cs", ""), + "path": "/".join(path.split("/")[:-1]) or "Assets", + } + + try: + result = run_command("manage_script", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Deleted: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@script.command("edit") +@click.argument("path") +@click.option( + "--edits", "-e", + required=True, + help='Edits as JSON array of {startLine, startCol, endLine, endCol, newText}.' +) +def edit(path: str, edits: str): + """Apply text edits to a script. + + \b + Examples: + unity-mcp script edit "Assets/Scripts/Player.cs" --edits '[{"startLine": 10, "startCol": 1, "endLine": 10, "endCol": 20, "newText": "// Modified"}]' + """ + config = get_config() + + try: + edits_list = json.loads(edits) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for edits: {e}") + sys.exit(1) + + params: dict[str, Any] = { + "uri": path, + "edits": edits_list, + } + + try: + result = run_command("apply_text_edits", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Applied edits to: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@script.command("validate") +@click.argument("path") +@click.option( + "--level", "-l", + type=click.Choice(["basic", "standard"]), + default="basic", + help="Validation level." +) +def validate(path: str, level: str): + """Validate a C# script for errors. + + \b + Examples: + unity-mcp script validate "Assets/Scripts/Player.cs" + unity-mcp script validate "Assets/Scripts/Player.cs" --level standard + """ + config = get_config() + + params: dict[str, Any] = { + "uri": path, + "level": level, + "include_diagnostics": True, + } + + try: + result = run_command("validate_script", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/ui.py b/Server/src/cli/commands/ui.py new file mode 100644 index 000000000..b05cc47a5 --- /dev/null +++ b/Server/src/cli/commands/ui.py @@ -0,0 +1,228 @@ +"""UI CLI commands - placeholder for future implementation.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def ui(): + """UI operations - create and modify UI elements.""" + pass + + +@ui.command("create-canvas") +@click.argument("name") +@click.option( + "--render-mode", + type=click.Choice(["ScreenSpaceOverlay", "ScreenSpaceCamera", "WorldSpace"]), + default="ScreenSpaceOverlay", + help="Canvas render mode." +) +def create_canvas(name: str, render_mode: str): + """Create a new Canvas. + + \b + Examples: + unity-mcp ui create-canvas "MainUI" + unity-mcp ui create-canvas "WorldUI" --render-mode WorldSpace + """ + config = get_config() + + try: + # Step 1: Create empty GameObject + result = run_command("manage_gameobject", { + "action": "create", + "name": name, + }, config) + + if not (result.get("success") or result.get("data") or result.get("result")): + click.echo(format_output(result, config.format)) + return + + # Step 2: Add Canvas components + for component in ["Canvas", "CanvasScaler", "GraphicRaycaster"]: + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": component, + }, config) + + # Step 3: Set render mode + render_mode_value = {"ScreenSpaceOverlay": 0, "ScreenSpaceCamera": 1, "WorldSpace": 2}.get(render_mode, 0) + run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Canvas", + "property": "renderMode", + "value": render_mode_value, + }, config) + + click.echo(format_output(result, config.format)) + print_success(f"Created Canvas: {name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@ui.command("create-text") +@click.argument("name") +@click.option( + "--parent", "-p", + required=True, + help="Parent Canvas or UI element." +) +@click.option( + "--text", "-t", + default="New Text", + help="Initial text content." +) +@click.option( + "--position", + nargs=2, + type=float, + default=(0, 0), + help="Anchored position X Y." +) +def create_text(name: str, parent: str, text: str, position: tuple): + """Create a UI Text element (TextMeshPro). + + \b + Examples: + unity-mcp ui create-text "TitleText" --parent "MainUI" --text "Hello World" + """ + config = get_config() + + try: + # Step 1: Create empty GameObject with parent + result = run_command("manage_gameobject", { + "action": "create", + "name": name, + "parent": parent, + }, config) + + if not (result.get("success") or result.get("data") or result.get("result")): + click.echo(format_output(result, config.format)) + return + + # Step 2: Add RectTransform and TextMeshProUGUI + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": "TextMeshProUGUI", + }, config) + + # Step 3: Set text content + run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "TextMeshProUGUI", + "property": "text", + "value": text, + }, config) + + click.echo(format_output(result, config.format)) + print_success(f"Created Text: {name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@ui.command("create-button") +@click.argument("name") +@click.option( + "--parent", "-p", + required=True, + help="Parent Canvas or UI element." +) +@click.option( + "--text", "-t", + default="Button", + help="Button label text." +) +def create_button(name: str, parent: str, text: str): + """Create a UI Button. + + \b + Examples: + unity-mcp ui create-button "StartButton" --parent "MainUI" --text "Start Game" + """ + config = get_config() + + try: + # Step 1: Create empty GameObject with parent + result = run_command("manage_gameobject", { + "action": "create", + "name": name, + "parent": parent, + }, config) + + if not (result.get("success") or result.get("data") or result.get("result")): + click.echo(format_output(result, config.format)) + return + + # Step 2: Add Button and Image components + for component in ["Image", "Button"]: + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": component, + }, config) + + click.echo(format_output(result, config.format)) + print_success(f"Created Button: {name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@ui.command("create-image") +@click.argument("name") +@click.option( + "--parent", "-p", + required=True, + help="Parent Canvas or UI element." +) +@click.option( + "--sprite", "-s", + default=None, + help="Sprite asset path." +) +def create_image(name: str, parent: str, sprite: Optional[str]): + """Create a UI Image. + + \b + Examples: + unity-mcp ui create-image "Background" --parent "MainUI" + unity-mcp ui create-image "Icon" --parent "MainUI" --sprite "Assets/Sprites/icon.png" + """ + config = get_config() + + try: + # Step 1: Create empty GameObject with parent + result = run_command("manage_gameobject", { + "action": "create", + "name": name, + "parent": parent, + }, config) + + if not (result.get("success") or result.get("data") or result.get("result")): + click.echo(format_output(result, config.format)) + return + + # Step 2: Add Image component + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": "Image", + }, config) + + click.echo(format_output(result, config.format)) + print_success(f"Created Image: {name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py new file mode 100644 index 000000000..cdfa6413c --- /dev/null +++ b/Server/src/cli/main.py @@ -0,0 +1,273 @@ +"""Unity MCP Command Line Interface - Main Entry Point.""" + +import sys +import click +from typing import Optional + +from cli import __version__ +from cli.utils.config import CLIConfig, set_config, get_config +from cli.utils.output import format_output, print_error, print_success, print_info +from cli.utils.connection import ( + run_command, + run_check_connection, + run_list_instances, + UnityConnectionError, + warn_if_remote_host, +) + + +# Context object to pass configuration between commands +class Context: + def __init__(self): + self.config: Optional[CLIConfig] = None + self.verbose: bool = False + + +pass_context = click.make_pass_decorator(Context, ensure=True) + + +@click.group() +@click.version_option(version=__version__, prog_name="unity-mcp") +@click.option( + "--host", "-h", + default="127.0.0.1", + envvar="UNITY_MCP_HOST", + help="MCP server host address." +) +@click.option( + "--port", "-p", + default=8080, + type=int, + envvar="UNITY_MCP_HTTP_PORT", + help="MCP server port." +) +@click.option( + "--timeout", "-t", + default=30, + type=int, + envvar="UNITY_MCP_TIMEOUT", + help="Command timeout in seconds." +) +@click.option( + "--format", "-f", + type=click.Choice(["text", "json", "table"]), + default="text", + envvar="UNITY_MCP_FORMAT", + help="Output format." +) +@click.option( + "--instance", "-i", + default=None, + envvar="UNITY_MCP_INSTANCE", + help="Target Unity instance (hash or Name@hash)." +) +@click.option( + "--verbose", "-v", + is_flag=True, + help="Enable verbose output." +) +@pass_context +def cli(ctx: Context, host: str, port: int, timeout: int, format: str, instance: Optional[str], verbose: bool): + """Unity MCP Command Line Interface. + + Control Unity Editor directly from the command line using the Model Context Protocol. + + \b + Examples: + unity-mcp status + unity-mcp gameobject find "Player" + unity-mcp scene hierarchy --format json + unity-mcp editor play + + \b + Environment Variables: + UNITY_MCP_HOST Server host (default: 127.0.0.1) + UNITY_MCP_HTTP_PORT Server port (default: 8080) + UNITY_MCP_TIMEOUT Timeout in seconds (default: 30) + UNITY_MCP_FORMAT Output format (default: text) + UNITY_MCP_INSTANCE Target Unity instance + """ + config = CLIConfig( + host=host, + port=port, + timeout=timeout, + format=format, + unity_instance=instance, + ) + + # Security warning for non-localhost connections + warn_if_remote_host(config) + + set_config(config) + ctx.config = config + ctx.verbose = verbose + + +@cli.command("status") +@pass_context +def status(ctx: Context): + """Check connection status to Unity MCP server.""" + config = ctx.config or get_config() + + click.echo(f"Checking connection to {config.host}:{config.port}...") + + if run_check_connection(config): + print_success(f"Connected to Unity MCP server at {config.host}:{config.port}") + + # Try to get Unity instances + try: + result = run_list_instances(config) + instances = result.get("instances", []) if isinstance(result, dict) else [] + if instances: + click.echo("\nConnected Unity instances:") + for inst in instances: + project = inst.get("project", "Unknown") + version = inst.get("unity_version", "Unknown") + hash_id = inst.get("hash", "")[:8] + click.echo(f" • {project} (Unity {version}) [{hash_id}]") + else: + print_info("No Unity instances currently connected") + except UnityConnectionError as e: + print_info(f"Could not retrieve Unity instances: {e}") + else: + print_error(f"Cannot connect to Unity MCP server at {config.host}:{config.port}") + sys.exit(1) + + +@cli.command("instances") +@pass_context +def list_instances(ctx: Context): + """List available Unity instances.""" + config = ctx.config or get_config() + + try: + instances = run_list_instances(config) + click.echo(format_output(instances, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@cli.command("raw") +@click.argument("command_type") +@click.argument("params", required=False, default="{}") +@pass_context +def raw_command(ctx: Context, command_type: str, params: str): + """Send a raw command to Unity. + + \b + Examples: + unity-mcp raw manage_scene '{"action": "get_hierarchy"}' + unity-mcp raw read_console '{"count": 10}' + """ + import json + config = ctx.config or get_config() + + try: + params_dict = json.loads(params) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON params: {e}") + sys.exit(1) + + try: + result = run_command(command_type, params_dict, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +# Import and register command groups +# These will be implemented in subsequent TODOs +def register_commands(): + """Register all command groups.""" + try: + from cli.commands.gameobject import gameobject + cli.add_command(gameobject) + except ImportError: + pass + + try: + from cli.commands.component import component + cli.add_command(component) + except ImportError: + pass + + try: + from cli.commands.scene import scene + cli.add_command(scene) + except ImportError: + pass + + try: + from cli.commands.asset import asset + cli.add_command(asset) + except ImportError: + pass + + try: + from cli.commands.script import script + cli.add_command(script) + except ImportError: + pass + + try: + from cli.commands.code import code + cli.add_command(code) + except ImportError: + pass + + try: + from cli.commands.editor import editor + cli.add_command(editor) + except ImportError: + pass + + try: + from cli.commands.prefab import prefab + cli.add_command(prefab) + except ImportError: + pass + + try: + from cli.commands.material import material + cli.add_command(material) + except ImportError: + pass + + try: + from cli.commands.lighting import lighting + cli.add_command(lighting) + except ImportError: + pass + + try: + from cli.commands.animation import animation + cli.add_command(animation) + except ImportError: + pass + + try: + from cli.commands.audio import audio + cli.add_command(audio) + except ImportError: + pass + + try: + from cli.commands.ui import ui + cli.add_command(ui) + except ImportError: + pass + + +# Register commands on import +register_commands() + + +def main(): + """Main entry point for the CLI.""" + cli() + + +if __name__ == "__main__": + main() diff --git a/Server/src/cli/utils/__init__.py b/Server/src/cli/utils/__init__.py new file mode 100644 index 000000000..622ccc4fc --- /dev/null +++ b/Server/src/cli/utils/__init__.py @@ -0,0 +1,31 @@ +"""CLI utility modules.""" + +from cli.utils.config import CLIConfig, get_config, set_config +from cli.utils.connection import ( + run_command, + run_check_connection, + run_list_instances, + UnityConnectionError, +) +from cli.utils.output import ( + format_output, + print_success, + print_error, + print_warning, + print_info, +) + +__all__ = [ + "CLIConfig", + "get_config", + "set_config", + "run_command", + "run_check_connection", + "run_list_instances", + "UnityConnectionError", + "format_output", + "print_success", + "print_error", + "print_warning", + "print_info", +] diff --git a/Server/src/cli/utils/config.py b/Server/src/cli/utils/config.py new file mode 100644 index 000000000..a80ce46af --- /dev/null +++ b/Server/src/cli/utils/config.py @@ -0,0 +1,45 @@ +"""CLI Configuration utilities.""" + +import os +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class CLIConfig: + """Configuration for CLI connection to Unity.""" + + host: str = "127.0.0.1" + port: int = 8080 + timeout: int = 30 + format: str = "text" # text, json, table + unity_instance: Optional[str] = None + + @classmethod + def from_env(cls) -> "CLIConfig": + """Create config from environment variables.""" + return cls( + host=os.environ.get("UNITY_MCP_HOST", "127.0.0.1"), + port=int(os.environ.get("UNITY_MCP_HTTP_PORT", "8080")), + timeout=int(os.environ.get("UNITY_MCP_TIMEOUT", "30")), + format=os.environ.get("UNITY_MCP_FORMAT", "text"), + unity_instance=os.environ.get("UNITY_MCP_INSTANCE"), + ) + + +# Global config instance +_config: Optional[CLIConfig] = None + + +def get_config() -> CLIConfig: + """Get the current CLI configuration.""" + global _config + if _config is None: + _config = CLIConfig.from_env() + return _config + + +def set_config(config: CLIConfig) -> None: + """Set the CLI configuration.""" + global _config + _config = config diff --git a/Server/src/cli/utils/connection.py b/Server/src/cli/utils/connection.py new file mode 100644 index 000000000..e107c5a8d --- /dev/null +++ b/Server/src/cli/utils/connection.py @@ -0,0 +1,190 @@ +"""Connection utilities for CLI to communicate with Unity via MCP server.""" + +import asyncio +import json +import sys +from typing import Any, Dict, Optional + +import httpx + +from cli.utils.config import get_config, CLIConfig + + +class UnityConnectionError(Exception): + """Raised when connection to Unity fails.""" + pass + + +def warn_if_remote_host(config: CLIConfig) -> None: + """Warn user if connecting to a non-localhost server. + + This is a security measure to alert users that connecting to remote + servers exposes Unity control to potential network attacks. + + Args: + config: CLI configuration with host setting + """ + import click + + local_hosts = ("127.0.0.1", "localhost", "::1", "0.0.0.0") + if config.host.lower() not in local_hosts: + click.echo( + "⚠️ Security Warning: Connecting to non-localhost server.\n" + " The MCP CLI has no authentication. Anyone on the network could\n" + " intercept commands or send unauthorized commands to Unity.\n" + " Only proceed if you trust this network.\n", + err=True + ) + + +async def send_command( + command_type: str, + params: Dict[str, Any], + config: Optional[CLIConfig] = None, + timeout: Optional[int] = None, +) -> Dict[str, Any]: + """Send a command to Unity via the MCP HTTP server. + + Args: + command_type: The command type (e.g., 'manage_gameobject', 'manage_scene') + params: Command parameters + config: Optional CLI configuration + timeout: Optional timeout override + + Returns: + Response dict from Unity + + Raises: + UnityConnectionError: If connection fails + """ + cfg = config or get_config() + url = f"http://{cfg.host}:{cfg.port}/api/command" + + payload = { + "type": command_type, + "params": params, + } + + if cfg.unity_instance: + payload["unity_instance"] = cfg.unity_instance + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + url, + json=payload, + timeout=timeout or 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 {timeout or 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_command( + command_type: str, + params: Dict[str, Any], + config: Optional[CLIConfig] = None, + timeout: Optional[int] = None, +) -> Dict[str, Any]: + """Synchronous wrapper for send_command. + + Args: + command_type: The command type + params: Command parameters + config: Optional CLI configuration + timeout: Optional timeout override + + Returns: + Response dict from Unity + """ + return asyncio.run(send_command(command_type, params, config, timeout)) + + +async def check_connection(config: Optional[CLIConfig] = None) -> bool: + """Check if we can connect to the Unity MCP server. + + Args: + config: Optional CLI configuration + + Returns: + True if connection successful, False otherwise + """ + cfg = config or get_config() + url = f"http://{cfg.host}:{cfg.port}/health" + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=5) + return response.status_code == 200 + except Exception: + return False + + +def run_check_connection(config: Optional[CLIConfig] = None) -> bool: + """Synchronous wrapper for check_connection.""" + return asyncio.run(check_connection(config)) + + +async def list_unity_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]: + """List available Unity instances. + + Args: + config: Optional CLI configuration + + Returns: + Dict with list of Unity instances + """ + cfg = config or get_config() + + # Try the new /api/instances endpoint first, fall back to /plugin/sessions + urls_to_try = [ + f"http://{cfg.host}:{cfg.port}/api/instances", + f"http://{cfg.host}:{cfg.port}/plugin/sessions", + ] + + async with httpx.AsyncClient() as client: + for url in urls_to_try: + try: + response = await client.get(url, timeout=10) + if response.status_code == 200: + data = response.json() + # Normalize response format + if "instances" in data: + return data + elif "sessions" in data: + # Convert sessions format to instances format + instances = [] + for session_id, details in data["sessions"].items(): + instances.append({ + "session_id": session_id, + "project": details.get("project", "Unknown"), + "hash": details.get("hash", ""), + "unity_version": details.get("unity_version", "Unknown"), + "connected_at": details.get("connected_at", ""), + }) + return {"success": True, "instances": instances} + except Exception: + continue + + raise UnityConnectionError("Failed to list Unity instances: No working endpoint found") + + +def run_list_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]: + """Synchronous wrapper for list_unity_instances.""" + return asyncio.run(list_unity_instances(config)) diff --git a/Server/src/cli/utils/output.py b/Server/src/cli/utils/output.py new file mode 100644 index 000000000..ad43493fa --- /dev/null +++ b/Server/src/cli/utils/output.py @@ -0,0 +1,188 @@ +"""Output formatting utilities for CLI.""" + +import json +import sys +from typing import Any, Dict, List, Optional, Union + + +def format_output(data: Any, format_type: str = "text") -> str: + """Format output based on requested format type. + + Args: + data: Data to format + format_type: One of 'text', 'json', 'table' + + Returns: + Formatted string + """ + if format_type == "json": + return format_as_json(data) + elif format_type == "table": + return format_as_table(data) + else: + return format_as_text(data) + + +def format_as_json(data: Any) -> str: + """Format data as pretty-printed JSON.""" + try: + return json.dumps(data, indent=2, default=str) + except (TypeError, ValueError) as e: + return json.dumps({"error": f"JSON serialization failed: {e}", "raw": str(data)}) + + +def format_as_text(data: Any, indent: int = 0) -> str: + """Format data as human-readable text.""" + prefix = " " * indent + + if data is None: + return f"{prefix}(none)" + + if isinstance(data, dict): + # Check for error response + if "success" in data and not data.get("success"): + error = data.get("error") or data.get("message") or "Unknown error" + return f"{prefix}❌ Error: {error}" + + # Check for success response with data + if "success" in data and data.get("success"): + result = data.get("data") or data.get("result") or data + if result != data: + return format_as_text(result, indent) + + lines = [] + for key, value in data.items(): + if key in ("success", "error", "message") and "success" in data: + continue # Skip meta fields + if isinstance(value, dict): + lines.append(f"{prefix}{key}:") + lines.append(format_as_text(value, indent + 1)) + elif isinstance(value, list): + lines.append(f"{prefix}{key}: [{len(value)} items]") + if len(value) <= 10: + for i, item in enumerate(value): + lines.append(f"{prefix} [{i}] {_format_list_item(item)}") + else: + for i, item in enumerate(value[:5]): + lines.append(f"{prefix} [{i}] {_format_list_item(item)}") + lines.append(f"{prefix} ... ({len(value) - 10} more)") + for i, item in enumerate(value[-5:], len(value) - 5): + lines.append(f"{prefix} [{i}] {_format_list_item(item)}") + else: + lines.append(f"{prefix}{key}: {value}") + return "\n".join(lines) + + if isinstance(data, list): + if not data: + return f"{prefix}(empty list)" + lines = [f"{prefix}[{len(data)} items]"] + for i, item in enumerate(data[:20]): + lines.append(f"{prefix} [{i}] {_format_list_item(item)}") + if len(data) > 20: + lines.append(f"{prefix} ... ({len(data) - 20} more)") + return "\n".join(lines) + + return f"{prefix}{data}" + + +def _format_list_item(item: Any) -> str: + """Format a single list item.""" + if isinstance(item, dict): + # Try to find a name/id field for display + name = item.get("name") or item.get("Name") or item.get("id") or item.get("Id") + if name: + extra = "" + if "instanceID" in item: + extra = f" (ID: {item['instanceID']})" + elif "path" in item: + extra = f" ({item['path']})" + return f"{name}{extra}" + # Fallback to compact representation + return json.dumps(item, default=str)[:80] + return str(item)[:80] + + +def format_as_table(data: Any) -> str: + """Format data as an ASCII table.""" + if isinstance(data, dict): + # Check for success response with data + if "success" in data and data.get("success"): + result = data.get("data") or data.get("result") or data.get("items") + if isinstance(result, list): + return _build_table(result) + + # Single dict as key-value table + rows = [[str(k), str(v)[:60]] for k, v in data.items()] + return _build_table(rows, headers=["Key", "Value"]) + + if isinstance(data, list): + return _build_table(data) + + return str(data) + + +def _build_table(data: List[Any], headers: Optional[List[str]] = None) -> str: + """Build an ASCII table from list data.""" + if not data: + return "(no data)" + + # Convert list of dicts to rows + if isinstance(data[0], dict): + if headers is None: + headers = list(data[0].keys()) + rows = [[str(item.get(h, ""))[:40] for h in headers] for item in data] + elif isinstance(data[0], (list, tuple)): + rows = [[str(cell)[:40] for cell in row] for row in data] + if headers is None: + headers = [f"Col{i}" for i in range(len(data[0]))] + else: + rows = [[str(item)[:60]] for item in data] + headers = headers or ["Value"] + + # Calculate column widths + col_widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = max(col_widths[i], len(cell)) + + # Build table + lines = [] + + # Header + header_line = " | ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers)) + lines.append(header_line) + lines.append("-+-".join("-" * w for w in col_widths)) + + # Rows + for row in rows[:50]: # Limit rows + row_line = " | ".join( + (row[i] if i < len(row) else "").ljust(col_widths[i]) + for i in range(len(headers)) + ) + lines.append(row_line) + + if len(rows) > 50: + lines.append(f"... ({len(rows) - 50} more rows)") + + return "\n".join(lines) + + +def print_success(message: str) -> None: + """Print a success message.""" + print(f"✅ {message}") + + +def print_error(message: str) -> None: + """Print an error message to stderr.""" + print(f"❌ {message}", file=sys.stderr) + + +def print_warning(message: str) -> None: + """Print a warning message.""" + print(f"⚠️ {message}") + + +def print_info(message: str) -> None: + """Print an info message.""" + print(f"ℹ️ {message}") diff --git a/Server/src/main.py b/Server/src/main.py index c46a4765c..74568413d 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -296,6 +296,67 @@ async def health_http(_: Request) -> JSONResponse: }) +@mcp.custom_route("/api/command", methods=["POST"]) +async def cli_command_route(request: Request) -> JSONResponse: + """REST endpoint for CLI commands to Unity.""" + try: + body = await request.json() + command_type = body.get("type") + params = body.get("params", {}) + unity_instance = body.get("unity_instance") + + if not command_type: + return JSONResponse({"success": False, "error": "Missing 'type' field"}, status_code=400) + + # Get available sessions + 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) + + # Find target session + session_id = None + 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: + session_id = sid + break + + if not session_id: + # Use first available session + session_id = next(iter(sessions.sessions.keys())) + + # Send command to Unity + result = await PluginHub.send_command(session_id, command_type, params) + return JSONResponse(result) + + except Exception as e: + logger.error(f"CLI command 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.""" + try: + sessions = await PluginHub.get_sessions() + instances = [] + for session_id, details in sessions.sessions.items(): + instances.append({ + "session_id": session_id, + "project": details.project, + "hash": details.hash, + "unity_version": details.unity_version, + "connected_at": details.connected_at, + }) + return JSONResponse({"success": True, "instances": instances}) + except Exception as e: + return JSONResponse({"success": False, "error": str(e)}, status_code=500) + + @mcp.custom_route("/plugin/sessions", methods=["GET"]) async def plugin_sessions_route(_: Request) -> JSONResponse: data = await PluginHub.get_sessions() diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py new file mode 100644 index 000000000..a4154896f --- /dev/null +++ b/Server/tests/test_cli.py @@ -0,0 +1,877 @@ +"""Unit tests for Unity MCP CLI.""" + +import json +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +from click.testing import CliRunner + +from cli.main import cli +from cli.utils.config import CLIConfig, get_config, set_config +from cli.utils.output import format_output, format_as_json, format_as_text, format_as_table +from cli.utils.connection import ( + send_command, + check_connection, + list_unity_instances, + UnityConnectionError, +) + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture +def runner(): + """Create a CLI test runner.""" + return CliRunner() + + +@pytest.fixture +def mock_config(): + """Create a mock CLI configuration.""" + return CLIConfig( + host="127.0.0.1", + port=8080, + timeout=30, + format="text", + unity_instance=None, + ) + + +@pytest.fixture +def mock_unity_response(): + """Standard successful Unity response.""" + return { + "success": True, + "message": "Operation successful", + "data": {"test": "data"} + } + + +@pytest.fixture +def mock_instances_response(): + """Mock Unity instances response.""" + return { + "success": True, + "instances": [ + { + "session_id": "test-session-123", + "project": "TestProject", + "hash": "abc123def456", + "unity_version": "2022.3.10f1", + "connected_at": "2024-01-01T00:00:00Z", + } + ] + } + + +@pytest.fixture +def mock_sessions_response(): + """Mock plugin sessions response (legacy format).""" + return { + "sessions": { + "test-session-123": { + "project": "TestProject", + "hash": "abc123def456", + "unity_version": "2022.3.10f1", + "connected_at": "2024-01-01T00:00:00Z", + } + } + } + + +# ============================================================================= +# Config Tests +# ============================================================================= + +class TestConfig: + """Tests for CLI configuration.""" + + def test_default_config(self): + """Test default configuration values.""" + config = CLIConfig() + assert config.host == "127.0.0.1" + assert config.port == 8080 + assert config.timeout == 30 + assert config.format == "text" + assert config.unity_instance is None + + def test_config_from_env(self, monkeypatch): + """Test configuration from environment variables.""" + monkeypatch.setenv("UNITY_MCP_HOST", "192.168.1.100") + monkeypatch.setenv("UNITY_MCP_HTTP_PORT", "9090") + monkeypatch.setenv("UNITY_MCP_TIMEOUT", "60") + monkeypatch.setenv("UNITY_MCP_FORMAT", "json") + monkeypatch.setenv("UNITY_MCP_INSTANCE", "MyProject") + + config = CLIConfig.from_env() + assert config.host == "192.168.1.100" + assert config.port == 9090 + assert config.timeout == 60 + assert config.format == "json" + assert config.unity_instance == "MyProject" + + def test_set_and_get_config(self, mock_config): + """Test setting and getting global config.""" + set_config(mock_config) + retrieved = get_config() + assert retrieved.host == mock_config.host + assert retrieved.port == mock_config.port + + +# ============================================================================= +# Output Formatting Tests +# ============================================================================= + +class TestOutputFormatting: + """Tests for output formatting utilities.""" + + def test_format_as_json(self): + """Test JSON formatting.""" + data = {"key": "value", "number": 42} + result = format_as_json(data) + parsed = json.loads(result) + assert parsed == data + + def test_format_as_json_with_complex_types(self): + """Test JSON formatting with complex types.""" + from datetime import datetime + data = {"timestamp": datetime(2024, 1, 1)} + result = format_as_json(data) + assert "2024" in result + + def test_format_as_text_success_response(self): + """Test text formatting for success response.""" + data = { + "success": True, + "message": "OK", + "data": {"name": "Player", "id": 123} + } + result = format_as_text(data) + assert "name" in result + assert "Player" in result + + def test_format_as_text_error_response(self): + """Test text formatting for error response.""" + data = {"success": False, "error": "Something went wrong"} + result = format_as_text(data) + assert "Error" in result + assert "Something went wrong" in result + + def test_format_as_text_list(self): + """Test text formatting for lists.""" + data = [{"name": "Item1"}, {"name": "Item2"}] + result = format_as_text(data) + assert "2 items" in result + + def test_format_as_table(self): + """Test table formatting.""" + data = [ + {"name": "Player", "id": 1}, + {"name": "Enemy", "id": 2}, + ] + result = format_as_table(data) + assert "name" in result + assert "Player" in result + assert "Enemy" in result + + def test_format_output_dispatch(self): + """Test format_output dispatches correctly.""" + data = {"key": "value"} + + json_result = format_output(data, "json") + assert json.loads(json_result) == data + + text_result = format_output(data, "text") + assert "key" in text_result + + table_result = format_output(data, "table") + assert "key" in table_result.lower() or "Key" in table_result + + +# ============================================================================= +# Connection Tests +# ============================================================================= + +class TestConnection: + """Tests for connection utilities.""" + + @pytest.mark.asyncio + async def test_check_connection_success(self): + """Test successful connection check.""" + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + result = await check_connection() + assert result is True + + @pytest.mark.asyncio + async def test_check_connection_failure(self): + """Test failed connection check.""" + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + side_effect=Exception("Connection refused") + ) + result = await check_connection() + assert result is False + + @pytest.mark.asyncio + async def test_send_command_success(self, mock_unity_response): + """Test successful command sending.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_unity_response + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + mock_response.raise_for_status = MagicMock() + + result = await send_command("test_command", {"param": "value"}) + assert result == mock_unity_response + + @pytest.mark.asyncio + async def test_send_command_connection_error(self): + """Test command sending with connection error.""" + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + side_effect=Exception("Connection refused") + ) + + with pytest.raises(UnityConnectionError): + await send_command("test_command", {}) + + @pytest.mark.asyncio + async def test_list_instances_from_sessions(self, mock_sessions_response): + """Test listing instances from /plugin/sessions endpoint.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_sessions_response + + with patch("httpx.AsyncClient") as mock_client: + # First call (api/instances) returns 404, second (plugin/sessions) succeeds + mock_get = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value.get = mock_get + + result = await list_unity_instances() + assert result["success"] is True + assert len(result["instances"]) == 1 + assert result["instances"][0]["project"] == "TestProject" + + +# ============================================================================= +# CLI Command Tests +# ============================================================================= + +class TestCLICommands: + """Tests for CLI commands.""" + + def test_cli_help(self, runner): + """Test CLI help command.""" + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Unity MCP Command Line Interface" in result.output + + def test_cli_version(self, runner): + """Test CLI version command.""" + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + + def test_status_connected(self, runner, mock_instances_response): + """Test status command when connected.""" + with patch("cli.main.run_check_connection", return_value=True): + with patch("cli.main.run_list_instances", return_value=mock_instances_response): + result = runner.invoke(cli, ["status"]) + assert result.exit_code == 0 + assert "Connected" in result.output + + def test_status_disconnected(self, runner): + """Test status command when disconnected.""" + with patch("cli.main.run_check_connection", return_value=False): + result = runner.invoke(cli, ["status"]) + assert result.exit_code == 1 + assert "Cannot connect" in result.output + + def test_instances_command(self, runner, mock_instances_response): + """Test instances command.""" + with patch("cli.main.run_list_instances", return_value=mock_instances_response): + result = runner.invoke(cli, ["instances"]) + assert result.exit_code == 0 + + def test_raw_command(self, runner, mock_unity_response): + """Test raw command.""" + with patch("cli.main.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["raw", "test_command", '{"param": "value"}']) + assert result.exit_code == 0 + + def test_raw_command_invalid_json(self, runner): + """Test raw command with invalid JSON.""" + result = runner.invoke(cli, ["raw", "test_command", "invalid json"]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + +# ============================================================================= +# GameObject Command Tests +# ============================================================================= + +class TestGameObjectCommands: + """Tests for GameObject CLI commands.""" + + def test_gameobject_find(self, runner, mock_unity_response): + """Test gameobject find command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["gameobject", "find", "Player"]) + assert result.exit_code == 0 + + def test_gameobject_find_with_options(self, runner, mock_unity_response): + """Test gameobject find with options.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "gameobject", "find", "Enemy", + "--method", "by_tag", + "--include-inactive", + "--limit", "100" + ]) + assert result.exit_code == 0 + + def test_gameobject_create(self, runner, mock_unity_response): + """Test gameobject create command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["gameobject", "create", "NewObject"]) + assert result.exit_code == 0 + + def test_gameobject_create_with_primitive(self, runner, mock_unity_response): + """Test gameobject create with primitive.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "gameobject", "create", "MyCube", + "--primitive", "Cube", + "--position", "0", "1", "0" + ]) + assert result.exit_code == 0 + + def test_gameobject_modify(self, runner, mock_unity_response): + """Test gameobject modify command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "gameobject", "modify", "Player", + "--position", "0", "5", "0" + ]) + assert result.exit_code == 0 + + def test_gameobject_delete(self, runner, mock_unity_response): + """Test gameobject delete command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["gameobject", "delete", "OldObject", "--force"]) + assert result.exit_code == 0 + + def test_gameobject_delete_confirmation(self, runner, mock_unity_response): + """Test gameobject delete with confirmation prompt.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["gameobject", "delete", "OldObject"], input="y\n") + assert result.exit_code == 0 + + def test_gameobject_duplicate(self, runner, mock_unity_response): + """Test gameobject duplicate command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "gameobject", "duplicate", "Player", + "--name", "Player2", + "--offset", "5", "0", "0" + ]) + assert result.exit_code == 0 + + def test_gameobject_move(self, runner, mock_unity_response): + """Test gameobject move command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "gameobject", "move", "Chair", + "--reference", "Table", + "--direction", "right", + "--distance", "2" + ]) + assert result.exit_code == 0 + + +# ============================================================================= +# Component Command Tests +# ============================================================================= + +class TestComponentCommands: + """Tests for Component CLI commands.""" + + def test_component_add(self, runner, mock_unity_response): + """Test component add command.""" + with patch("cli.commands.component.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["component", "add", "Player", "Rigidbody"]) + assert result.exit_code == 0 + + def test_component_remove(self, runner, mock_unity_response): + """Test component remove command.""" + with patch("cli.commands.component.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["component", "remove", "Player", "Rigidbody", "--force"]) + assert result.exit_code == 0 + + def test_component_set(self, runner, mock_unity_response): + """Test component set command.""" + with patch("cli.commands.component.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["component", "set", "Player", "Rigidbody", "mass", "5.0"]) + assert result.exit_code == 0 + + def test_component_modify(self, runner, mock_unity_response): + """Test component modify command.""" + with patch("cli.commands.component.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "component", "modify", "Player", "Rigidbody", + "--properties", '{"mass": 5.0, "useGravity": false}' + ]) + assert result.exit_code == 0 + + +# ============================================================================= +# Scene Command Tests +# ============================================================================= + +class TestSceneCommands: + """Tests for Scene CLI commands.""" + + def test_scene_hierarchy(self, runner, mock_unity_response): + """Test scene hierarchy command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "hierarchy"]) + assert result.exit_code == 0 + + def test_scene_hierarchy_with_options(self, runner, mock_unity_response): + """Test scene hierarchy with options.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "scene", "hierarchy", + "--max-depth", "5", + "--include-transform" + ]) + assert result.exit_code == 0 + + def test_scene_active(self, runner, mock_unity_response): + """Test scene active command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "active"]) + assert result.exit_code == 0 + + def test_scene_load(self, runner, mock_unity_response): + """Test scene load command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "load", "Assets/Scenes/Main.unity"]) + assert result.exit_code == 0 + + def test_scene_save(self, runner, mock_unity_response): + """Test scene save command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "save"]) + assert result.exit_code == 0 + + def test_scene_create(self, runner, mock_unity_response): + """Test scene create command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "create", "NewLevel"]) + assert result.exit_code == 0 + + def test_scene_screenshot(self, runner, mock_unity_response): + """Test scene screenshot command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "screenshot", "--filename", "test"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Asset Command Tests +# ============================================================================= + +class TestAssetCommands: + """Tests for Asset CLI commands.""" + + def test_asset_search(self, runner, mock_unity_response): + """Test asset search command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["asset", "search", "*.prefab"]) + assert result.exit_code == 0 + + def test_asset_info(self, runner, mock_unity_response): + """Test asset info command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["asset", "info", "Assets/Materials/Red.mat"]) + assert result.exit_code == 0 + + def test_asset_create(self, runner, mock_unity_response): + """Test asset create command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["asset", "create", "Assets/Materials/New.mat", "Material"]) + assert result.exit_code == 0 + + def test_asset_delete(self, runner, mock_unity_response): + """Test asset delete command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["asset", "delete", "Assets/Old.mat", "--force"]) + assert result.exit_code == 0 + + def test_asset_duplicate(self, runner, mock_unity_response): + """Test asset duplicate command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "asset", "duplicate", + "Assets/Materials/Red.mat", + "Assets/Materials/RedCopy.mat" + ]) + assert result.exit_code == 0 + + def test_asset_move(self, runner, mock_unity_response): + """Test asset move command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "asset", "move", + "Assets/Old/Mat.mat", + "Assets/New/Mat.mat" + ]) + assert result.exit_code == 0 + + def test_asset_mkdir(self, runner, mock_unity_response): + """Test asset mkdir command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["asset", "mkdir", "Assets/NewFolder"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Editor Command Tests +# ============================================================================= + +class TestEditorCommands: + """Tests for Editor CLI commands.""" + + def test_editor_play(self, runner, mock_unity_response): + """Test editor play command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "play"]) + assert result.exit_code == 0 + + def test_editor_pause(self, runner, mock_unity_response): + """Test editor pause command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "pause"]) + assert result.exit_code == 0 + + def test_editor_stop(self, runner, mock_unity_response): + """Test editor stop command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "stop"]) + assert result.exit_code == 0 + + def test_editor_console(self, runner, mock_unity_response): + """Test editor console command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "console"]) + assert result.exit_code == 0 + + def test_editor_console_clear(self, runner, mock_unity_response): + """Test editor console clear command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "console", "--clear"]) + assert result.exit_code == 0 + + def test_editor_add_tag(self, runner, mock_unity_response): + """Test editor add-tag command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "add-tag", "Enemy"]) + assert result.exit_code == 0 + + def test_editor_add_layer(self, runner, mock_unity_response): + """Test editor add-layer command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "add-layer", "Interactable"]) + assert result.exit_code == 0 + + def test_editor_menu(self, runner, mock_unity_response): + """Test editor menu command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "menu", "File/Save"]) + assert result.exit_code == 0 + + def test_editor_tests(self, runner, mock_unity_response): + """Test editor tests command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "tests", "--mode", "EditMode"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Prefab Command Tests +# ============================================================================= + +class TestPrefabCommands: + """Tests for Prefab CLI commands.""" + + def test_prefab_open(self, runner, mock_unity_response): + """Test prefab open command.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["prefab", "open", "Assets/Prefabs/Player.prefab"]) + assert result.exit_code == 0 + + def test_prefab_close(self, runner, mock_unity_response): + """Test prefab close command.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["prefab", "close"]) + assert result.exit_code == 0 + + def test_prefab_save(self, runner, mock_unity_response): + """Test prefab save command.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["prefab", "save"]) + assert result.exit_code == 0 + + def test_prefab_create(self, runner, mock_unity_response): + """Test prefab create command.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "prefab", "create", "Player", "Assets/Prefabs/Player.prefab" + ]) + assert result.exit_code == 0 + + +# ============================================================================= +# Material Command Tests +# ============================================================================= + +class TestMaterialCommands: + """Tests for Material CLI commands.""" + + def test_material_info(self, runner, mock_unity_response): + """Test material info command.""" + with patch("cli.commands.material.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["material", "info", "Assets/Materials/Red.mat"]) + assert result.exit_code == 0 + + def test_material_create(self, runner, mock_unity_response): + """Test material create command.""" + with patch("cli.commands.material.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["material", "create", "Assets/Materials/New.mat"]) + assert result.exit_code == 0 + + def test_material_set_color(self, runner, mock_unity_response): + """Test material set-color command.""" + with patch("cli.commands.material.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "material", "set-color", "Assets/Materials/Red.mat", + "1", "0", "0" + ]) + assert result.exit_code == 0 + + def test_material_set_property(self, runner, mock_unity_response): + """Test material set-property command.""" + with patch("cli.commands.material.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "material", "set-property", "Assets/Materials/Mat.mat", + "_Metallic", "0.5" + ]) + assert result.exit_code == 0 + + def test_material_assign(self, runner, mock_unity_response): + """Test material assign command.""" + with patch("cli.commands.material.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "material", "assign", "Assets/Materials/Red.mat", "Cube" + ]) + assert result.exit_code == 0 + + +# ============================================================================= +# Script Command Tests +# ============================================================================= + +class TestScriptCommands: + """Tests for Script CLI commands.""" + + def test_script_create(self, runner, mock_unity_response): + """Test script create command.""" + with patch("cli.commands.script.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["script", "create", "PlayerController"]) + assert result.exit_code == 0 + + def test_script_create_with_options(self, runner, mock_unity_response): + """Test script create with options.""" + with patch("cli.commands.script.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "script", "create", "EnemyData", + "--type", "ScriptableObject", + "--namespace", "MyGame" + ]) + assert result.exit_code == 0 + + def test_script_read(self, runner): + """Test script read command.""" + mock_response = { + "success": True, + "data": {"content": "using UnityEngine;\n\npublic class Test {}"} + } + with patch("cli.commands.script.run_command", return_value=mock_response): + result = runner.invoke(cli, ["script", "read", "Assets/Scripts/Test.cs"]) + assert result.exit_code == 0 + + def test_script_delete(self, runner, mock_unity_response): + """Test script delete command.""" + with patch("cli.commands.script.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["script", "delete", "Assets/Scripts/Old.cs", "--force"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Global Options Tests +# ============================================================================= + +class TestGlobalOptions: + """Tests for global CLI options.""" + + def test_custom_host(self, runner, mock_unity_response): + """Test custom host option.""" + with patch("cli.main.run_check_connection", return_value=True): + with patch("cli.main.run_list_instances", return_value={"instances": []}): + result = runner.invoke(cli, ["--host", "192.168.1.100", "status"]) + assert result.exit_code == 0 + + def test_custom_port(self, runner, mock_unity_response): + """Test custom port option.""" + with patch("cli.main.run_check_connection", return_value=True): + with patch("cli.main.run_list_instances", return_value={"instances": []}): + result = runner.invoke(cli, ["--port", "9090", "status"]) + assert result.exit_code == 0 + + def test_json_format(self, runner, mock_unity_response): + """Test JSON output format.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["--format", "json", "scene", "active"]) + assert result.exit_code == 0 + + def test_table_format(self, runner, mock_unity_response): + """Test table output format.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["--format", "table", "scene", "active"]) + assert result.exit_code == 0 + + def test_timeout_option(self, runner, mock_unity_response): + """Test timeout option.""" + with patch("cli.main.run_check_connection", return_value=True): + with patch("cli.main.run_list_instances", return_value={"instances": []}): + result = runner.invoke(cli, ["--timeout", "60", "status"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Error Handling Tests +# ============================================================================= + +class TestErrorHandling: + """Tests for error handling.""" + + def test_connection_error_handling(self, runner): + """Test connection error is handled gracefully.""" + with patch("cli.commands.scene.run_command", side_effect=UnityConnectionError("Connection failed")): + result = runner.invoke(cli, ["scene", "hierarchy"]) + assert result.exit_code == 1 + assert "Connection failed" in result.output or "Error" in result.output + + def test_invalid_json_params(self, runner): + """Test invalid JSON parameters are handled.""" + result = runner.invoke(cli, [ + "component", "modify", "Player", "Rigidbody", + "--properties", "not valid json" + ]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + def test_missing_required_argument(self, runner): + """Test missing required argument.""" + result = runner.invoke(cli, ["gameobject", "find"]) + assert result.exit_code != 0 + assert "Missing argument" in result.output + + +# ============================================================================= +# Integration-style Tests (with mocked responses) +# ============================================================================= + +class TestIntegration: + """Integration-style tests with realistic response data.""" + + def test_full_gameobject_workflow(self, runner): + """Test a full GameObject workflow.""" + create_response = { + "success": True, + "message": "GameObject created", + "data": {"instanceID": -12345, "name": "TestObject"} + } + modify_response = { + "success": True, + "message": "GameObject modified" + } + delete_response = { + "success": True, + "message": "GameObject deleted" + } + + # Create + with patch("cli.commands.gameobject.run_command", return_value=create_response): + result = runner.invoke(cli, ["gameobject", "create", "TestObject", "--primitive", "Cube"]) + assert result.exit_code == 0 + assert "Created" in result.output + + # Modify + with patch("cli.commands.gameobject.run_command", return_value=modify_response): + result = runner.invoke(cli, ["gameobject", "modify", "TestObject", "--position", "0", "5", "0"]) + assert result.exit_code == 0 + + # Delete + with patch("cli.commands.gameobject.run_command", return_value=delete_response): + result = runner.invoke(cli, ["gameobject", "delete", "TestObject", "--force"]) + assert result.exit_code == 0 + assert "Deleted" in result.output + + def test_scene_hierarchy_with_data(self, runner): + """Test scene hierarchy with realistic data.""" + hierarchy_response = { + "success": True, + "data": { + "nodes": [ + {"name": "Main Camera", "instanceID": -100, "childCount": 0}, + {"name": "Directional Light", "instanceID": -200, "childCount": 0}, + {"name": "Player", "instanceID": -300, "childCount": 2}, + ] + } + } + + with patch("cli.commands.scene.run_command", return_value=hierarchy_response): + result = runner.invoke(cli, ["scene", "hierarchy"]) + assert result.exit_code == 0 + + def test_find_gameobjects_with_results(self, runner): + """Test finding GameObjects with results.""" + find_response = { + "success": True, + "message": "Found 3 GameObjects", + "data": { + "instanceIDs": [-100, -200, -300], + "count": 3, + "hasMore": False + } + } + + with patch("cli.commands.gameobject.run_command", return_value=find_response): + result = runner.invoke(cli, ["gameobject", "find", "Camera"]) + assert result.exit_code == 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/Server/uv.lock b/Server/uv.lock index 40c43970d..6af0a2b03 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -915,6 +915,7 @@ name = "mcpforunityserver" version = "9.0.3" source = { editable = "." } dependencies = [ + { name = "click" }, { name = "fastapi" }, { name = "fastmcp" }, { name = "httpx" }, @@ -933,6 +934,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "click", specifier = ">=8.1.0" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "fastmcp", specifier = "==2.14.1" }, { name = "httpx", specifier = ">=0.27.2" }, From 90cd8b9fc383a952482bbdfa2280587d3209b8b9 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 11 Jan 2026 23:06:38 -0500 Subject: [PATCH 02/24] Update based on AI feedback --- Server/src/cli/commands/animation.py | 7 ++-- Server/src/cli/commands/gameobject.py | 24 ++++++++------ Server/src/cli/commands/lighting.py | 46 ++++++++++++++++++--------- Server/src/cli/commands/material.py | 2 +- Server/src/cli/commands/script.py | 18 ++++++++--- Server/src/cli/commands/ui.py | 3 +- Server/src/cli/utils/config.py | 17 ++++++++-- 7 files changed, 81 insertions(+), 36 deletions(-) diff --git a/Server/src/cli/commands/animation.py b/Server/src/cli/commands/animation.py index 5a9182c28..bcaac9899 100644 --- a/Server/src/cli/commands/animation.py +++ b/Server/src/cli/commands/animation.py @@ -30,6 +30,7 @@ def animation(): default=None, help="How to find the target." ) + def play(target: str, state_name: str, layer: int, search_method: Optional[str]): """Play an animation state on a target's Animator. @@ -42,11 +43,11 @@ def play(target: str, state_name: str, layer: int, search_method: Optional[str]) # Set Animator parameter to trigger state params: dict[str, Any] = { - "action": "set_property", + "action": "invoke_method", "target": target, "componentType": "Animator", - "property": "Play", - "value": state_name, + "method": "Play", + "args": [state_name, layer], } if search_method: diff --git a/Server/src/cli/commands/gameobject.py b/Server/src/cli/commands/gameobject.py index 1176c8149..083ba4479 100644 --- a/Server/src/cli/commands/gameobject.py +++ b/Server/src/cli/commands/gameobject.py @@ -6,7 +6,7 @@ from typing import Optional, Tuple, Any from cli.utils.config import get_config -from cli.utils.output import format_output, print_error, print_success +from cli.utils.output import format_output, print_error, print_success, print_warning from cli.utils.connection import run_command, UnityConnectionError @@ -181,12 +181,18 @@ def create( # Add components separately since componentsToAdd doesn't work if components and (result.get("success") or result.get("data") or result.get("result")): component_list = [c.strip() for c in components.split(",")] + failed_components = [] for component in component_list: - run_command("manage_components", { - "action": "add", - "target": name, - "componentType": component, - }, config) + try: + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": component, + }, config) + except UnityConnectionError: + failed_components.append(component) + if failed_components: + print_warning(f"Failed to add components: {', '.join(failed_components)}") click.echo(format_output(result, config.format)) if result.get("success") or result.get("result"): @@ -288,7 +294,7 @@ def modify( """ config = get_config() - params = { + params: dict[str, Any] = { "action": "modify", "target": target, } @@ -405,7 +411,7 @@ def duplicate( """ config = get_config() - params = { + params: dict[str, Any] = { "action": "duplicate", "target": target, } @@ -475,7 +481,7 @@ def move( """ config = get_config() - params = { + params: dict[str, Any] = { "action": "move_relative", "target": target, "reference_object": reference, diff --git a/Server/src/cli/commands/lighting.py b/Server/src/cli/commands/lighting.py index 9b125093b..ab4a67b14 100644 --- a/Server/src/cli/commands/lighting.py +++ b/Server/src/cli/commands/lighting.py @@ -63,19 +63,23 @@ def create(name: str, light_type: str, position: Tuple[float, float, float], col "position": list(position), }, config) - if not (create_result.get("success") or create_result.get("data") or create_result.get("result")): + if not (create_result.get("success")): click.echo(format_output(create_result, config.format)) return # Step 2: Add Light component using manage_components - run_command("manage_components", { + add_result = run_command("manage_components", { "action": "add", "target": name, "componentType": "Light", }, config) + if not add_result.get("success"): + click.echo(format_output(add_result, config.format)) + return + # Step 3: Set light type using manage_components set_property - run_command("manage_components", { + type_result = run_command("manage_components", { "action": "set_property", "target": name, "componentType": "Light", @@ -83,25 +87,37 @@ def create(name: str, light_type: str, position: Tuple[float, float, float], col "value": light_type, }, config) + if not type_result.get("success"): + click.echo(format_output(type_result, config.format)) + return + # Step 4: Set color if provided if color: - run_command("manage_components", { - "action": "set_property", - "target": name, - "componentType": "Light", - "property": "color", - "value": {"r": color[0], "g": color[1], "b": color[2], "a": 1}, + color_result = run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Light", + "property": "color", + "value": {"r": color[0], "g": color[1], "b": color[2], "a": 1}, }, config) + + if not color_result.get("success"): + click.echo(format_output(color_result, config.format)) + return # Step 5: Set intensity if provided if intensity is not None: - run_command("manage_components", { - "action": "set_property", - "target": name, - "componentType": "Light", - "property": "intensity", - "value": intensity, + intensity_result = run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Light", + "property": "intensity", + "value": intensity, }, config) + + if not intensity_result.get("success"): + click.echo(format_output(intensity_result, config.format)) + return # Output the result click.echo(format_output(create_result, config.format)) diff --git a/Server/src/cli/commands/material.py b/Server/src/cli/commands/material.py index 4488c2fd1..d1d0d4d58 100644 --- a/Server/src/cli/commands/material.py +++ b/Server/src/cli/commands/material.py @@ -89,7 +89,7 @@ def create(path: str, shader: str, properties: Optional[str]): @click.argument("r", type=float) @click.argument("g", type=float) @click.argument("b", type=float) -@click.argument("a", type=float, default=1.0) +@click.argument("a", type=float, default=1.0, show_default=True) @click.option( "--property", "-p", default="_Color", diff --git a/Server/src/cli/commands/script.py b/Server/src/cli/commands/script.py index 69f2dfc89..5608a2d75 100644 --- a/Server/src/cli/commands/script.py +++ b/Server/src/cli/commands/script.py @@ -98,10 +98,15 @@ def read(path: str, start_line: Optional[int], line_count: Optional[int]): """ config = get_config() + parts = path.rsplit("/", 1) + filename = parts[-1] + directory = parts[0] if len(parts) > 1 else "Assets" + name = filename[:-3] if filename.endswith(".cs") else filename + params: dict[str, Any] = { "action": "read", - "name": path.split("/")[-1].replace(".cs", ""), - "path": "/".join(path.split("/")[:-1]) or "Assets", + "name": name, + "path": directory, } if start_line: @@ -144,10 +149,15 @@ def delete(path: str, force: bool): if not force: click.confirm(f"Delete script '{path}'?", abort=True) + parts = path.rsplit("/", 1) + filename = parts[-1] + directory = parts[0] if len(parts) > 1 else "Assets" + name = filename[:-3] if filename.endswith(".cs") else filename + params: dict[str, Any] = { "action": "delete", - "name": path.split("/")[-1].replace(".cs", ""), - "path": "/".join(path.split("/")[:-1]) or "Assets", + "name": name, + "path": directory, } try: diff --git a/Server/src/cli/commands/ui.py b/Server/src/cli/commands/ui.py index b05cc47a5..746c2fa0a 100644 --- a/Server/src/cli/commands/ui.py +++ b/Server/src/cli/commands/ui.py @@ -103,6 +103,7 @@ def create_text(name: str, parent: str, text: str, position: tuple): "action": "create", "name": name, "parent": parent, + "position": list(position), }, config) if not (result.get("success") or result.get("data") or result.get("result")): @@ -144,7 +145,7 @@ def create_text(name: str, parent: str, text: str, position: tuple): default="Button", help="Button label text." ) -def create_button(name: str, parent: str, text: str): +def create_button(name: str, parent: str, text: str): #text current placeholder """Create a UI Button. \b diff --git a/Server/src/cli/utils/config.py b/Server/src/cli/utils/config.py index a80ce46af..d6878250f 100644 --- a/Server/src/cli/utils/config.py +++ b/Server/src/cli/utils/config.py @@ -17,11 +17,22 @@ class CLIConfig: @classmethod def from_env(cls) -> "CLIConfig": - """Create config from environment variables.""" + port_raw = os.environ.get("UNITY_MCP_HTTP_PORT", "8080") + try: + port = int(port_raw) + except (ValueError, TypeError): + raise ValueError(f"Invalid UNITY_MCP_HTTP_PORT value: {port_raw!r}") + + timeout_raw = os.environ.get("UNITY_MCP_TIMEOUT", "30") + try: + timeout = int(timeout_raw) + except (ValueError, TypeError): + raise ValueError(f"Invalid UNITY_MCP_TIMEOUT value: {timeout_raw!r}") + return cls( host=os.environ.get("UNITY_MCP_HOST", "127.0.0.1"), - port=int(os.environ.get("UNITY_MCP_HTTP_PORT", "8080")), - timeout=int(os.environ.get("UNITY_MCP_TIMEOUT", "30")), + port=port, + timeout=timeout, format=os.environ.get("UNITY_MCP_FORMAT", "text"), unity_instance=os.environ.get("UNITY_MCP_INSTANCE"), ) From 1b17d81dadad753575fb44e2f8ab3e8d0ecf3b1a Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 11 Jan 2026 23:11:36 -0500 Subject: [PATCH 03/24] Fixes main.py error --- Server/src/main.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Server/src/main.py b/Server/src/main.py index 74568413d..85c39382f 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -490,8 +490,16 @@ def main(): # Allow individual host/port to override URL components http_host = args.http_host or os.environ.get( "UNITY_MCP_HTTP_HOST") or parsed_url.hostname or "localhost" - http_port = args.http_port or (int(os.environ.get("UNITY_MCP_HTTP_PORT")) if os.environ.get( - "UNITY_MCP_HTTP_PORT") else None) or parsed_url.port or 8080 + + # Safely parse optional environment port (may be None or non-numeric) + _env_port_str = os.environ.get("UNITY_MCP_HTTP_PORT") + try: + _env_port = int(_env_port_str) if _env_port_str is not None else None + except ValueError: + logger.warning("Invalid UNITY_MCP_HTTP_PORT value '%s', ignoring", _env_port_str) + _env_port = None + + http_port = args.http_port or _env_port or parsed_url.port or 8080 os.environ["UNITY_MCP_HTTP_HOST"] = http_host os.environ["UNITY_MCP_HTTP_PORT"] = str(http_port) @@ -526,8 +534,7 @@ def main(): parsed_url = urlparse(http_url) host = args.http_host or os.environ.get( "UNITY_MCP_HTTP_HOST") or parsed_url.hostname or "localhost" - port = args.http_port or (int(os.environ.get("UNITY_MCP_HTTP_PORT")) if os.environ.get( - "UNITY_MCP_HTTP_PORT") else None) or parsed_url.port or 8080 + port = args.http_port or _env_port or parsed_url.port or 8080 logger.info(f"Starting FastMCP with HTTP transport on {host}:{port}") mcp.run(transport=transport, host=host, port=port) else: From fdadc52fb93b99f007ffc5f22b18a44d2ca73453 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 11 Jan 2026 23:20:29 -0500 Subject: [PATCH 04/24] Update for further error fix --- Server/src/cli/commands/audio.py | 3 +++ Server/src/cli/commands/code.py | 2 +- Server/src/cli/commands/lighting.py | 4 ++-- Server/src/cli/commands/ui.py | 10 ++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Server/src/cli/commands/audio.py b/Server/src/cli/commands/audio.py index af0d5917e..7ee472278 100644 --- a/Server/src/cli/commands/audio.py +++ b/Server/src/cli/commands/audio.py @@ -45,6 +45,9 @@ def play(target: str, clip: Optional[str], search_method: Optional[str]): "property": "Play", "value": True, } + + if clip: + params["clip"] = clip if search_method: params["searchMethod"] = search_method diff --git a/Server/src/cli/commands/code.py b/Server/src/cli/commands/code.py index 1b00596a8..7a24964b4 100644 --- a/Server/src/cli/commands/code.py +++ b/Server/src/cli/commands/code.py @@ -1,4 +1,4 @@ -"""Code CLI commands - search and read source code.""" +"""Code CLI commands - read source code. search might be implemented later (but can be totally achievable with AI).""" import sys import click diff --git a/Server/src/cli/commands/lighting.py b/Server/src/cli/commands/lighting.py index ab4a67b14..4010bdd03 100644 --- a/Server/src/cli/commands/lighting.py +++ b/Server/src/cli/commands/lighting.py @@ -103,7 +103,7 @@ def create(name: str, light_type: str, position: Tuple[float, float, float], col if not color_result.get("success"): click.echo(format_output(color_result, config.format)) - return + return # Step 5: Set intensity if provided if intensity is not None: @@ -117,7 +117,7 @@ def create(name: str, light_type: str, position: Tuple[float, float, float], col if not intensity_result.get("success"): click.echo(format_output(intensity_result, config.format)) - return + return # Output the result click.echo(format_output(create_result, config.format)) diff --git a/Server/src/cli/commands/ui.py b/Server/src/cli/commands/ui.py index 746c2fa0a..35f3b9beb 100644 --- a/Server/src/cli/commands/ui.py +++ b/Server/src/cli/commands/ui.py @@ -222,6 +222,16 @@ def create_image(name: str, parent: str, sprite: Optional[str]): "componentType": "Image", }, config) + # Step 3: Set sprite if provided + if sprite: + run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Image", + "property": "sprite", + "value": sprite, + }, config) + click.echo(format_output(result, config.format)) print_success(f"Created Image: {name}") except UnityConnectionError as e: From d716843322f719d9e09d7516c4ad43690f68b97f Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:23:36 -0500 Subject: [PATCH 05/24] Update based on AI --- Server/src/cli/commands/animation.py | 10 ++++----- Server/src/cli/commands/code.py | 3 ++- Server/src/cli/commands/ui.py | 32 +++++++++++++++++++++++----- Server/src/main.py | 1 + 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/Server/src/cli/commands/animation.py b/Server/src/cli/commands/animation.py index bcaac9899..19de4b2b5 100644 --- a/Server/src/cli/commands/animation.py +++ b/Server/src/cli/commands/animation.py @@ -22,7 +22,7 @@ def animation(): "--layer", "-l", default=0, type=int, - help="Animator layer." + help="Animator layer(TODO)." ) @click.option( "--search-method", @@ -30,7 +30,6 @@ def animation(): default=None, help="How to find the target." ) - def play(target: str, state_name: str, layer: int, search_method: Optional[str]): """Play an animation state on a target's Animator. @@ -43,11 +42,12 @@ def play(target: str, state_name: str, layer: int, search_method: Optional[str]) # Set Animator parameter to trigger state params: dict[str, Any] = { - "action": "invoke_method", + "action": "set_property", "target": target, "componentType": "Animator", - "method": "Play", - "args": [state_name, layer], + "property": "Play", + "value": state_name, + "layer": layer, } if search_method: diff --git a/Server/src/cli/commands/code.py b/Server/src/cli/commands/code.py index 7a24964b4..86e06e7f6 100644 --- a/Server/src/cli/commands/code.py +++ b/Server/src/cli/commands/code.py @@ -1,6 +1,7 @@ """Code CLI commands - read source code. search might be implemented later (but can be totally achievable with AI).""" import sys +import os import click from typing import Optional, Any @@ -41,7 +42,7 @@ def read(path: str, start_line: Optional[int], line_count: Optional[int]): # Extract name and directory from path parts = path.replace("\\", "/").split("/") - filename = parts[-1].replace(".cs", "") + filename = os.path.splitext(parts[-1])[0] directory = "/".join(parts[:-1]) or "Assets" params: dict[str, Any] = { diff --git a/Server/src/cli/commands/ui.py b/Server/src/cli/commands/ui.py index 35f3b9beb..c61bf1763 100644 --- a/Server/src/cli/commands/ui.py +++ b/Server/src/cli/commands/ui.py @@ -161,11 +161,11 @@ def create_button(name: str, parent: str, text: str): #text current placeholder "name": name, "parent": parent, }, config) - + if not (result.get("success") or result.get("data") or result.get("result")): click.echo(format_output(result, config.format)) return - + # Step 2: Add Button and Image components for component in ["Image", "Button"]: run_command("manage_components", { @@ -173,9 +173,31 @@ def create_button(name: str, parent: str, text: str): #text current placeholder "target": name, "componentType": component, }, config) - + + # Step 3: Create child label GameObject + label_name = f"{name}_Label" + label_result = run_command("manage_gameobject", { + "action": "create", + "name": label_name, + "parent": name, + }, config) + + # Step 4: Add TextMeshProUGUI to label and set text + run_command("manage_components", { + "action": "add", + "target": label_name, + "componentType": "TextMeshProUGUI", + }, config) + run_command("manage_components", { + "action": "set_property", + "target": label_name, + "componentType": "TextMeshProUGUI", + "property": "text", + "value": text, + }, config) + click.echo(format_output(result, config.format)) - print_success(f"Created Button: {name}") + print_success(f"Created Button: {name} (with label '{text}')") except UnityConnectionError as e: print_error(str(e)) sys.exit(1) @@ -221,7 +243,7 @@ def create_image(name: str, parent: str, sprite: Optional[str]): "target": name, "componentType": "Image", }, config) - + # Step 3: Set sprite if provided if sprite: run_command("manage_components", { diff --git a/Server/src/main.py b/Server/src/main.py index 85c39382f..92b483ab4 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -301,6 +301,7 @@ async def cli_command_route(request: Request) -> JSONResponse: """REST endpoint for CLI commands to Unity.""" try: body = await request.json() + command_type = body.get("type") params = body.get("params", {}) unity_instance = body.get("unity_instance") From 0759f537c4e3245ebe26a5e89964546ee6b848a0 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:22:35 -0500 Subject: [PATCH 06/24] Update script.py --- Server/src/cli/commands/script.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/src/cli/commands/script.py b/Server/src/cli/commands/script.py index 5608a2d75..d58d87d11 100644 --- a/Server/src/cli/commands/script.py +++ b/Server/src/cli/commands/script.py @@ -119,8 +119,8 @@ def read(path: str, start_line: Optional[int], line_count: Optional[int]): # For read, just output the content directly if result.get("success") and result.get("data"): data = result.get("data", {}) - if isinstance(data, dict) and "content" in data: - click.echo(data["content"]) + if isinstance(data, dict) and "contents" in data: + click.echo(data["contents"]) else: click.echo(format_output(result, config.format)) else: From 832633a694fa71c828cd8b798880d1d7d1252b7b Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:15:31 -0500 Subject: [PATCH 07/24] Update with better coverage and Tool Readme --- Server/src/cli/commands/batch.py | 183 ++++++++++++ Server/src/cli/commands/code.py | 116 ++++++++ Server/src/cli/commands/editor.py | 194 +++++++++++- Server/src/cli/commands/instance.py | 99 +++++++ Server/src/cli/commands/material.py | 2 +- Server/src/cli/commands/shader.py | 237 +++++++++++++++ Server/src/cli/commands/vfx.py | 437 ++++++++++++++++++++++++++++ Server/src/cli/main.py | 28 ++ Server/tests/test_cli.py | 304 +++++++++++++++++++ docs/CLI_USAGE.md | 393 +++++++++++++++++++++++++ 10 files changed, 1985 insertions(+), 8 deletions(-) create mode 100644 Server/src/cli/commands/batch.py create mode 100644 Server/src/cli/commands/instance.py create mode 100644 Server/src/cli/commands/shader.py create mode 100644 Server/src/cli/commands/vfx.py create mode 100644 docs/CLI_USAGE.md diff --git a/Server/src/cli/commands/batch.py b/Server/src/cli/commands/batch.py new file mode 100644 index 000000000..dc8da5091 --- /dev/null +++ b/Server/src/cli/commands/batch.py @@ -0,0 +1,183 @@ +"""Batch CLI commands for executing multiple Unity operations efficiently.""" + +import sys +import json +import click +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 + + +@click.group() +def batch(): + """Batch operations - execute multiple commands efficiently.""" + pass + + +@batch.command("run") +@click.argument("file", type=click.Path(exists=True)) +@click.option("--parallel", is_flag=True, help="Execute read-only commands in parallel.") +@click.option("--fail-fast", is_flag=True, help="Stop on first failure.") +def batch_run(file: str, parallel: bool, fail_fast: bool): + """Execute commands from a JSON file. + + The JSON file should contain an array of command objects with 'tool' and 'params' keys. + + \\b + File format: + [ + {"tool": "manage_gameobject", "params": {"action": "create", "name": "Cube1"}}, + {"tool": "manage_gameobject", "params": {"action": "create", "name": "Cube2"}}, + {"tool": "manage_components", "params": {"action": "add", "target": "Cube1", "componentType": "Rigidbody"}} + ] + + \\b + Examples: + unity-mcp batch run commands.json + unity-mcp batch run setup.json --parallel + unity-mcp batch run critical.json --fail-fast + """ + config = get_config() + + try: + with open(file, 'r') as f: + commands = json.load(f) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON in file: {e}") + sys.exit(1) + except IOError as e: + print_error(f"Error reading file: {e}") + sys.exit(1) + + if not isinstance(commands, list): + print_error("JSON file must contain an array of commands") + sys.exit(1) + + if len(commands) > 25: + print_error(f"Maximum 25 commands per batch, got {len(commands)}") + sys.exit(1) + + params: dict[str, Any] = {"commands": commands} + if parallel: + params["parallel"] = True + if fail_fast: + params["failFast"] = True + + click.echo(f"Executing {len(commands)} commands...") + + try: + result = run_command("batch_execute", params, config) + click.echo(format_output(result, config.format)) + + if isinstance(result, dict): + results = result.get("data", {}).get("results", []) + succeeded = sum(1 for r in results if r.get("success")) + failed = len(results) - succeeded + + if failed == 0: + print_success(f"All {succeeded} commands completed successfully") + else: + print_info(f"{succeeded} succeeded, {failed} failed") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@batch.command("inline") +@click.argument("commands_json") +@click.option("--parallel", is_flag=True, help="Execute read-only commands in parallel.") +@click.option("--fail-fast", is_flag=True, help="Stop on first failure.") +def batch_inline(commands_json: str, parallel: bool, fail_fast: bool): + """Execute commands from inline JSON. + + \\b + Examples: + unity-mcp batch inline '[{"tool": "manage_scene", "params": {"action": "get_active"}}]' + + unity-mcp batch inline '[ + {"tool": "manage_gameobject", "params": {"action": "create", "name": "A", "primitiveType": "Cube"}}, + {"tool": "manage_gameobject", "params": {"action": "create", "name": "B", "primitiveType": "Sphere"}} + ]' + """ + config = get_config() + + try: + commands = json.loads(commands_json) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON: {e}") + sys.exit(1) + + if not isinstance(commands, list): + print_error("Commands must be an array") + sys.exit(1) + + if len(commands) > 25: + print_error(f"Maximum 25 commands per batch, got {len(commands)}") + sys.exit(1) + + params: dict[str, Any] = {"commands": commands} + if parallel: + params["parallel"] = True + if fail_fast: + params["failFast"] = True + + try: + result = run_command("batch_execute", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@batch.command("template") +@click.option("--output", "-o", type=click.Path(), help="Output file (default: stdout)") +def batch_template(output: Optional[str]): + """Generate a sample batch commands file. + + \\b + Examples: + unity-mcp batch template > commands.json + unity-mcp batch template -o my_batch.json + """ + template = [ + { + "tool": "manage_scene", + "params": {"action": "get_active"} + }, + { + "tool": "manage_gameobject", + "params": { + "action": "create", + "name": "BatchCube", + "primitiveType": "Cube", + "position": [0, 1, 0] + } + }, + { + "tool": "manage_components", + "params": { + "action": "add", + "target": "BatchCube", + "componentType": "Rigidbody" + } + }, + { + "tool": "manage_gameobject", + "params": { + "action": "modify", + "target": "BatchCube", + "position": [0, 5, 0] + } + } + ] + + json_output = json.dumps(template, indent=2) + + if output: + with open(output, 'w') as f: + f.write(json_output) + print_success(f"Template written to: {output}") + else: + click.echo(json_output) diff --git a/Server/src/cli/commands/code.py b/Server/src/cli/commands/code.py index 86e06e7f6..cf5e6c9f9 100644 --- a/Server/src/cli/commands/code.py +++ b/Server/src/cli/commands/code.py @@ -70,3 +70,119 @@ def read(path: str, start_line: Optional[int], line_count: Optional[int]): except UnityConnectionError as e: print_error(str(e)) sys.exit(1) + + +@code.command("search") +@click.argument("pattern") +@click.argument("path") +@click.option( + "--max-results", "-n", + default=50, + type=int, + help="Maximum number of results (default: 50)." +) +@click.option( + "--case-sensitive", "-c", + is_flag=True, + help="Make search case-sensitive (default: case-insensitive)." +) +def search(pattern: str, path: str, max_results: int, case_sensitive: bool): + """Search for patterns in Unity scripts using regex. + + PATTERN is a regex pattern to search for. + PATH is the script path (e.g., Assets/Scripts/Player.cs). + + \\b + Examples: + unity-mcp code search "class.*Player" "Assets/Scripts/Player.cs" + unity-mcp code search "private.*int" "Assets/Scripts/GameManager.cs" + unity-mcp code search "TODO|FIXME" "Assets/Scripts/Utils.cs" + """ + import re + import base64 + + config = get_config() + + # Extract name and directory from path + parts = path.replace("\\", "/").split("/") + filename = os.path.splitext(parts[-1])[0] + directory = "/".join(parts[:-1]) or "Assets" + + # Step 1: Read the file via Unity's manage_script + read_params: dict[str, Any] = { + "action": "read", + "name": filename, + "path": directory, + } + + try: + result = run_command("manage_script", read_params, config) + + # Handle nested response structure: {status, result: {success, data}} + inner_result = result.get("result", result) + + if not inner_result.get("success") and result.get("status") != "success": + click.echo(format_output(result, config.format)) + return + + # Get file contents from nested data + data = inner_result.get("data", {}) + contents = data.get("contents") + + # Handle base64 encoded content + if not contents and data.get("contentsEncoded") and data.get("encodedContents"): + try: + contents = base64.b64decode(data["encodedContents"]).decode("utf-8", "replace") + except (ValueError, TypeError): + pass + + if not contents: + print_error(f"Could not read file content from {path}") + sys.exit(1) + + # Step 2: Perform regex search locally + flags = re.MULTILINE + if not case_sensitive: + flags |= re.IGNORECASE + + try: + regex = re.compile(pattern, flags) + except re.error as e: + print_error(f"Invalid regex pattern: {e}") + sys.exit(1) + + found = list(regex.finditer(contents)) + + if not found: + print_info(f"No matches found for pattern: {pattern}") + return + + results = [] + for m in found[:max_results]: + start_idx = m.start() + + # Calculate line number + line_num = contents.count('\n', 0, start_idx) + 1 + + # Get line content + line_start = contents.rfind('\n', 0, start_idx) + 1 + line_end = contents.find('\n', start_idx) + if line_end == -1: + line_end = len(contents) + + line_content = contents[line_start:line_end].strip() + + results.append({ + "line": line_num, + "content": line_content, + "match": m.group(0), + }) + + # Display results + click.echo(f"Found {len(results)} matches (total: {len(found)}):\n") + for match in results: + click.echo(f" Line {match['line']}: {match['content']}") + + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index ea35f3dc5..c6bb0f71d 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -271,29 +271,209 @@ def execute_menu(menu_path: str): help="Test mode to run." ) @click.option( - "--timeout", "-t", - default=None, + "--async", "async_mode", + is_flag=True, + help="Run asynchronously and return job ID for polling." +) +@click.option( + "--wait", "-w", type=int, - help="Timeout in seconds." + default=None, + help="Wait up to N seconds for completion (default: no wait)." +) +@click.option( + "--details", + is_flag=True, + help="Include detailed results for all tests." +) +@click.option( + "--failed-only", + is_flag=True, + help="Include details for failed/skipped tests only." ) -def run_tests(mode: str, timeout: Optional[int]): +def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, failed_only: bool): """Run Unity tests. \b Examples: unity-mcp editor tests unity-mcp editor tests --mode PlayMode - unity-mcp editor tests --timeout 60 + unity-mcp editor tests --async + unity-mcp editor tests --wait 60 --failed-only """ config = get_config() params: dict[str, Any] = {"mode": mode} - if timeout: - params["timeout_seconds"] = timeout + if details: + params["include_details"] = True + if failed_only: + params["include_failed_tests"] = True try: result = run_command("run_tests", params, config) + + # For async mode, just show job ID + if async_mode and result.get("success"): + job_id = result.get("data", {}).get("job_id") + if job_id: + click.echo(f"Test job started: {job_id}") + print_info("Poll with: unity-mcp editor poll-test " + job_id) + return + + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("poll-test") +@click.argument("job_id") +@click.option( + "--wait", "-w", + type=int, + default=30, + help="Wait up to N seconds for completion (default: 30)." +) +@click.option( + "--details", + is_flag=True, + help="Include detailed results for all tests." +) +@click.option( + "--failed-only", + is_flag=True, + help="Include details for failed/skipped tests only." +) +def poll_test(job_id: str, wait: int, details: bool, failed_only: bool): + """Poll an async test job for status/results. + + \b + Examples: + unity-mcp editor poll-test abc123 + unity-mcp editor poll-test abc123 --wait 60 + unity-mcp editor poll-test abc123 --failed-only + """ + config = get_config() + + params: dict[str, Any] = {"job_id": job_id} + if wait: + params["wait_timeout"] = wait + if details: + params["include_details"] = True + if failed_only: + params["include_failed_tests"] = True + + try: + result = run_command("get_test_job", params, config) + click.echo(format_output(result, config.format)) + + if isinstance(result, dict) and result.get("success"): + data = result.get("data", {}) + status = data.get("status", "unknown") + if status == "succeeded": + print_success("Tests completed successfully") + elif status == "failed": + summary = data.get("result", {}).get("summary", {}) + failed = summary.get("failed", 0) + print_error(f"Tests failed: {failed} failures") + elif status == "running": + progress = data.get("progress", {}) + completed = progress.get("completed", 0) + total = progress.get("total", 0) + print_info(f"Tests running: {completed}/{total}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("refresh") +@click.option( + "--mode", + type=click.Choice(["if_dirty", "force"]), + default="if_dirty", + help="Refresh mode." +) +@click.option( + "--scope", + type=click.Choice(["assets", "scripts", "all"]), + default="all", + help="What to refresh." +) +@click.option( + "--compile", + is_flag=True, + help="Request script compilation." +) +@click.option( + "--no-wait", + is_flag=True, + help="Don't wait for refresh to complete." +) +def refresh(mode: str, scope: str, compile: bool, no_wait: bool): + """Force Unity to refresh assets/scripts. + + \b + Examples: + unity-mcp editor refresh + unity-mcp editor refresh --mode force + unity-mcp editor refresh --compile + unity-mcp editor refresh --scope scripts --compile + """ + config = get_config() + + params: dict[str, Any] = { + "mode": mode, + "scope": scope, + "wait_for_ready": not no_wait, + } + if compile: + params["compile"] = "request" + + try: + click.echo("Refreshing Unity...") + result = run_command("refresh_unity", params, config) click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Unity refreshed") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("custom-tool") +@click.argument("tool_name") +@click.option( + "--params", "-p", + default="{}", + help="Tool parameters as JSON." +) +def custom_tool(tool_name: str, params: str): + """Execute a custom Unity tool. + + Custom tools are registered by Unity projects via the MCP plugin. + + \b + Examples: + unity-mcp editor custom-tool "MyCustomTool" + unity-mcp editor custom-tool "BuildPipeline" --params '{"target": "Android"}' + """ + import json + config = get_config() + + try: + params_dict = json.loads(params) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for params: {e}") + sys.exit(1) + + try: + result = run_command("execute_custom_tool", { + "tool_name": tool_name, + "parameters": params_dict, + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Executed custom tool: {tool_name}") except UnityConnectionError as e: print_error(str(e)) sys.exit(1) diff --git a/Server/src/cli/commands/instance.py b/Server/src/cli/commands/instance.py new file mode 100644 index 000000000..8312342cc --- /dev/null +++ b/Server/src/cli/commands/instance.py @@ -0,0 +1,99 @@ +"""Instance CLI commands for managing Unity instances.""" + +import sys +import click +from typing import Optional + +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, run_list_instances, UnityConnectionError + + +@click.group() +def instance(): + """Unity instance management - list, select, and view instances.""" + pass + + +@instance.command("list") +def list_instances(): + """List available Unity instances. + + \\b + Examples: + unity-mcp instance list + """ + config = get_config() + + try: + result = run_list_instances(config) + instances = result.get("instances", []) if isinstance(result, dict) else [] + + if not instances: + print_info("No Unity instances currently connected") + return + + click.echo("Available Unity instances:") + for inst in instances: + project = inst.get("project", "Unknown") + version = inst.get("unity_version", "Unknown") + hash_id = inst.get("hash", "") + session_id = inst.get("session_id", "") + + # Format: ProjectName@hash (Unity version) + display_id = f"{project}@{hash_id}" if hash_id else project + click.echo(f" • {display_id} (Unity {version})") + if session_id: + click.echo(f" Session: {session_id[:8]}...") + + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@instance.command("set") +@click.argument("instance_id") +def set_instance(instance_id: str): + """Set the active Unity instance. + + INSTANCE_ID can be Name@hash or just a hash prefix. + + \\b + Examples: + unity-mcp instance set "MyProject@abc123" + unity-mcp instance set abc123 + """ + config = get_config() + + try: + result = run_command("set_active_instance", { + "instance": instance_id, + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + data = result.get("data", {}) + active = data.get("instance", instance_id) + print_success(f"Active instance set to: {active}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@instance.command("current") +def current_instance(): + """Show the currently selected Unity instance. + + \\b + Examples: + unity-mcp instance current + """ + config = get_config() + + # The current instance is typically shown in telemetry or needs to be tracked + # For now, we can show the configured instance from CLI options + if config.unity_instance: + click.echo(f"Configured instance: {config.unity_instance}") + else: + print_info("No instance explicitly set. Using default (auto-select single instance).") + print_info("Use 'unity-mcp instance list' to see available instances.") + print_info("Use 'unity-mcp instance set ' to select one.") diff --git a/Server/src/cli/commands/material.py b/Server/src/cli/commands/material.py index d1d0d4d58..4488c2fd1 100644 --- a/Server/src/cli/commands/material.py +++ b/Server/src/cli/commands/material.py @@ -89,7 +89,7 @@ def create(path: str, shader: str, properties: Optional[str]): @click.argument("r", type=float) @click.argument("g", type=float) @click.argument("b", type=float) -@click.argument("a", type=float, default=1.0, show_default=True) +@click.argument("a", type=float, default=1.0) @click.option( "--property", "-p", default="_Color", diff --git a/Server/src/cli/commands/shader.py b/Server/src/cli/commands/shader.py new file mode 100644 index 000000000..0199a5d23 --- /dev/null +++ b/Server/src/cli/commands/shader.py @@ -0,0 +1,237 @@ +"""Shader CLI commands for managing Unity shaders.""" + +import sys +import click +from typing import Optional + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def shader(): + """Shader operations - create, read, update, delete shaders.""" + pass + + +@shader.command("read") +@click.argument("path") +def read_shader(path: str): + """Read a shader file. + + \\b + Examples: + unity-mcp shader read "Assets/Shaders/MyShader.shader" + """ + config = get_config() + + # Extract name from path + import os + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + + try: + result = run_command("manage_shader", { + "action": "read", + "name": name, + "path": directory or "Assets/", + }, config) + + # If successful, display the contents nicely + if result.get("success") and result.get("data", {}).get("contents"): + click.echo(result["data"]["contents"]) + else: + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@shader.command("create") +@click.argument("name") +@click.option( + "--path", "-p", + default="Assets/Shaders", + help="Directory to create shader in." +) +@click.option( + "--contents", "-c", + default=None, + help="Shader code (reads from stdin if not provided)." +) +@click.option( + "--file", "-f", + "file_path", + default=None, + type=click.Path(exists=True), + help="Read shader code from file." +) +def create_shader(name: str, path: str, contents: Optional[str], file_path: Optional[str]): + """Create a new shader. + + \\b + Examples: + unity-mcp shader create "MyShader" --path "Assets/Shaders" + unity-mcp shader create "MyShader" --file local_shader.shader + echo "Shader code..." | unity-mcp shader create "MyShader" + """ + config = get_config() + + # Get contents from file, option, or stdin + if file_path: + with open(file_path, 'r') as f: + shader_contents = f.read() + elif contents: + shader_contents = contents + else: + # Read from stdin if available + import sys + if not sys.stdin.isatty(): + shader_contents = sys.stdin.read() + else: + # Provide default shader template + shader_contents = f'''Shader "Custom/{name}" +{{ + Properties + {{ + _Color ("Color", Color) = (1,1,1,1) + _MainTex ("Albedo (RGB)", 2D) = "white" {{}} + }} + SubShader + {{ + Tags {{ "RenderType"="Opaque" }} + LOD 200 + + CGPROGRAM + #pragma surface surf Standard fullforwardshadows + #pragma target 3.0 + + sampler2D _MainTex; + fixed4 _Color; + + struct Input + {{ + float2 uv_MainTex; + }}; + + void surf (Input IN, inout SurfaceOutputStandard o) + {{ + fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color; + o.Albedo = c.rgb; + o.Alpha = c.a; + }} + ENDCG + }} + FallBack "Diffuse" +}} +''' + + try: + result = run_command("manage_shader", { + "action": "create", + "name": name, + "path": path, + "contents": shader_contents, + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created shader: {path}/{name}.shader") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@shader.command("update") +@click.argument("path") +@click.option( + "--contents", "-c", + default=None, + help="New shader code." +) +@click.option( + "--file", "-f", + "file_path", + default=None, + type=click.Path(exists=True), + help="Read shader code from file." +) +def update_shader(path: str, contents: Optional[str], file_path: Optional[str]): + """Update an existing shader. + + \\b + Examples: + unity-mcp shader update "Assets/Shaders/MyShader.shader" --file updated.shader + echo "New shader code" | unity-mcp shader update "Assets/Shaders/MyShader.shader" + """ + config = get_config() + + import os + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + + # Get contents from file, option, or stdin + if file_path: + with open(file_path, 'r') as f: + shader_contents = f.read() + elif contents: + shader_contents = contents + else: + import sys + if not sys.stdin.isatty(): + shader_contents = sys.stdin.read() + else: + print_error("No shader contents provided. Use --contents, --file, or pipe via stdin.") + sys.exit(1) + + try: + result = run_command("manage_shader", { + "action": "update", + "name": name, + "path": directory or "Assets/", + "contents": shader_contents, + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Updated shader: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@shader.command("delete") +@click.argument("path") +@click.option( + "--force", "-f", + is_flag=True, + help="Skip confirmation prompt." +) +def delete_shader(path: str, force: bool): + """Delete a shader. + + \\b + Examples: + unity-mcp shader delete "Assets/Shaders/OldShader.shader" + unity-mcp shader delete "Assets/Shaders/OldShader.shader" --force + """ + config = get_config() + + if not force: + click.confirm(f"Delete shader '{path}'?", abort=True) + + import os + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + + try: + result = run_command("manage_shader", { + "action": "delete", + "name": name, + "path": directory or "Assets/", + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Deleted shader: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/vfx.py b/Server/src/cli/commands/vfx.py new file mode 100644 index 000000000..224ab7f28 --- /dev/null +++ b/Server/src/cli/commands/vfx.py @@ -0,0 +1,437 @@ +"""VFX CLI commands for managing Unity visual effects.""" + +import sys +import json +import click +from typing import Optional, Tuple, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def vfx(): + """VFX operations - particle systems, line renderers, trails.""" + pass + + +# ============================================================================= +# Particle System Commands +# ============================================================================= + +@vfx.group() +def particle(): + """Particle system operations.""" + pass + + +@particle.command("info") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_info(target: str, search_method: Optional[str]): + """Get particle system info. + + \\b + Examples: + unity-mcp vfx particle info "Fire" + unity-mcp vfx particle info "-12345" --search-method by_id + """ + config = get_config() + params: dict[str, Any] = {"action": "particle_get_info", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@particle.command("play") +@click.argument("target") +@click.option("--with-children", is_flag=True, help="Also play child particle systems.") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_play(target: str, with_children: bool, search_method: Optional[str]): + """Play a particle system. + + \\b + Examples: + unity-mcp vfx particle play "Fire" + unity-mcp vfx particle play "Effects" --with-children + """ + config = get_config() + params: dict[str, Any] = {"action": "particle_play", "target": target} + if with_children: + params["withChildren"] = True + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Playing particle system: {target}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@particle.command("stop") +@click.argument("target") +@click.option("--with-children", is_flag=True, help="Also stop child particle systems.") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_stop(target: str, with_children: bool, search_method: Optional[str]): + """Stop a particle system.""" + config = get_config() + params: dict[str, Any] = {"action": "particle_stop", "target": target} + if with_children: + params["withChildren"] = True + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Stopped particle system: {target}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@particle.command("pause") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_pause(target: str, search_method: Optional[str]): + """Pause a particle system.""" + config = get_config() + params: dict[str, Any] = {"action": "particle_pause", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@particle.command("restart") +@click.argument("target") +@click.option("--with-children", is_flag=True) +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_restart(target: str, with_children: bool, search_method: Optional[str]): + """Restart a particle system.""" + config = get_config() + params: dict[str, Any] = {"action": "particle_restart", "target": target} + if with_children: + params["withChildren"] = True + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@particle.command("clear") +@click.argument("target") +@click.option("--with-children", is_flag=True) +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_clear(target: str, with_children: bool, search_method: Optional[str]): + """Clear all particles from a particle system.""" + config = get_config() + params: dict[str, Any] = {"action": "particle_clear", "target": target} + if with_children: + params["withChildren"] = True + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +# ============================================================================= +# Line Renderer Commands +# ============================================================================= + +@vfx.group() +def line(): + """Line renderer operations.""" + pass + + +@line.command("info") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def line_info(target: str, search_method: Optional[str]): + """Get line renderer info. + + \\b + Examples: + unity-mcp vfx line info "LaserBeam" + """ + config = get_config() + params: dict[str, Any] = {"action": "line_get_info", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@line.command("set-positions") +@click.argument("target") +@click.option("--positions", "-p", required=True, help='Positions as JSON array: [[0,0,0], [1,1,1], [2,0,0]]') +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def line_set_positions(target: str, positions: str, search_method: Optional[str]): + """Set all positions on a line renderer. + + \\b + Examples: + unity-mcp vfx line set-positions "Line" --positions "[[0,0,0], [5,2,0], [10,0,0]]" + """ + config = get_config() + + try: + positions_list = json.loads(positions) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for positions: {e}") + sys.exit(1) + + params: dict[str, Any] = { + "action": "line_set_positions", + "target": target, + "positions": positions_list, + } + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@line.command("create-line") +@click.argument("target") +@click.option("--start", nargs=3, type=float, required=True, help="Start point X Y Z") +@click.option("--end", nargs=3, type=float, required=True, help="End point X Y Z") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def line_create_line(target: str, start: Tuple[float, float, float], end: Tuple[float, float, float], search_method: Optional[str]): + """Create a simple line between two points. + + \\b + Examples: + unity-mcp vfx line create-line "MyLine" --start 0 0 0 --end 10 5 0 + """ + config = get_config() + params: dict[str, Any] = { + "action": "line_create_line", + "target": target, + "start": list(start), + "end": list(end), + } + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@line.command("create-circle") +@click.argument("target") +@click.option("--center", nargs=3, type=float, default=(0, 0, 0), help="Center point X Y Z") +@click.option("--radius", type=float, required=True, help="Circle radius") +@click.option("--segments", type=int, default=32, help="Number of segments") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def line_create_circle(target: str, center: Tuple[float, float, float], radius: float, segments: int, search_method: Optional[str]): + """Create a circle shape. + + \\b + Examples: + unity-mcp vfx line create-circle "Circle" --radius 5 --segments 64 + unity-mcp vfx line create-circle "Ring" --center 0 2 0 --radius 3 + """ + config = get_config() + params: dict[str, Any] = { + "action": "line_create_circle", + "target": target, + "center": list(center), + "radius": radius, + "segments": segments, + } + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@line.command("clear") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def line_clear(target: str, search_method: Optional[str]): + """Clear all positions from a line renderer.""" + config = get_config() + params: dict[str, Any] = {"action": "line_clear", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +# ============================================================================= +# Trail Renderer Commands +# ============================================================================= + +@vfx.group() +def trail(): + """Trail renderer operations.""" + pass + + +@trail.command("info") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def trail_info(target: str, search_method: Optional[str]): + """Get trail renderer info.""" + config = get_config() + params: dict[str, Any] = {"action": "trail_get_info", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@trail.command("set-time") +@click.argument("target") +@click.argument("duration", type=float) +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def trail_set_time(target: str, duration: float, search_method: Optional[str]): + """Set trail duration. + + \\b + Examples: + unity-mcp vfx trail set-time "PlayerTrail" 2.0 + """ + config = get_config() + params: dict[str, Any] = { + "action": "trail_set_time", + "target": target, + "time": duration, + } + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@trail.command("clear") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def trail_clear(target: str, search_method: Optional[str]): + """Clear a trail renderer.""" + config = get_config() + params: dict[str, Any] = {"action": "trail_clear", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +# ============================================================================= +# Raw Command (escape hatch for all VFX actions) +# ============================================================================= + +@vfx.command("raw") +@click.argument("action") +@click.argument("target", required=False) +@click.option("--params", "-p", default="{}", help="Additional parameters as JSON.") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def vfx_raw(action: str, target: Optional[str], params: str, search_method: Optional[str]): + """Execute any VFX action directly. + + For advanced users who need access to all 60+ VFX actions. + + \\b + Actions include: + particle_*: particle_set_main, particle_set_emission, particle_set_shape, ... + vfx_*: vfx_set_float, vfx_send_event, vfx_play, ... + line_*: line_create_arc, line_create_bezier, ... + trail_*: trail_set_width, trail_set_color, ... + + \\b + Examples: + unity-mcp vfx raw particle_set_main "Fire" --params '{"duration": 5, "looping": true}' + unity-mcp vfx raw line_create_arc "Arc" --params '{"radius": 3, "startAngle": 0, "endAngle": 180}' + unity-mcp vfx raw vfx_send_event "Explosion" --params '{"eventName": "OnSpawn"}' + """ + config = get_config() + + try: + extra_params = json.loads(params) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for params: {e}") + sys.exit(1) + + request_params: dict[str, Any] = {"action": action} + if target: + request_params["target"] = target + if search_method: + request_params["searchMethod"] = search_method + + # Merge extra params + request_params.update(extra_params) + + try: + result = run_command("manage_vfx", request_params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index cdfa6413c..aa9649278 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -258,6 +258,34 @@ def register_commands(): cli.add_command(ui) except ImportError: pass + + # New commands - instance management + try: + from cli.commands.instance import instance + cli.add_command(instance) + except ImportError: + pass + + # New commands - shader management + try: + from cli.commands.shader import shader + cli.add_command(shader) + except ImportError: + pass + + # New commands - VFX management + try: + from cli.commands.vfx import vfx + cli.add_command(vfx) + except ImportError: + pass + + # New commands - batch execution + try: + from cli.commands.batch import batch + cli.add_command(batch) + except ImportError: + pass # Register commands on import diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index a4154896f..77a55e405 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -873,5 +873,309 @@ def test_find_gameobjects_with_results(self, runner): assert result.exit_code == 0 +# ============================================================================= +# Instance Command Tests +# ============================================================================= + +class TestInstanceCommands: + """Tests for instance management commands.""" + + def test_instance_list(self, runner): + """Test listing Unity instances.""" + mock_instances = { + "instances": [ + {"project": "TestProject", "hash": "abc123", "unity_version": "2022.3.10f1", "session_id": "sess-1"} + ] + } + with patch("cli.commands.instance.run_list_instances", return_value=mock_instances): + result = runner.invoke(cli, ["instance", "list"]) + assert result.exit_code == 0 + assert "TestProject" in result.output + + def test_instance_set(self, runner, mock_unity_response): + """Test setting active instance.""" + with patch("cli.commands.instance.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["instance", "set", "TestProject@abc123"]) + assert result.exit_code == 0 + + def test_instance_current(self, runner): + """Test showing current instance.""" + result = runner.invoke(cli, ["instance", "current"]) + assert result.exit_code == 0 + # Should show info message about no instance set + assert "instance" in result.output.lower() + + +# ============================================================================= +# Shader Command Tests +# ============================================================================= + +class TestShaderCommands: + """Tests for shader commands.""" + + def test_shader_read(self, runner): + """Test reading a shader.""" + read_response = { + "success": True, + "data": {"contents": "Shader \"Custom/Test\" { ... }"} + } + with patch("cli.commands.shader.run_command", return_value=read_response): + result = runner.invoke(cli, ["shader", "read", "Assets/Shaders/Test.shader"]) + assert result.exit_code == 0 + + def test_shader_create(self, runner, mock_unity_response): + """Test creating a shader.""" + with patch("cli.commands.shader.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["shader", "create", "NewShader", "--path", "Assets/Shaders"]) + assert result.exit_code == 0 + + def test_shader_delete(self, runner, mock_unity_response): + """Test deleting a shader.""" + with patch("cli.commands.shader.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["shader", "delete", "Assets/Shaders/Old.shader", "--force"]) + assert result.exit_code == 0 + + +# ============================================================================= +# VFX Command Tests +# ============================================================================= + +class TestVfxCommands: + """Tests for VFX commands.""" + + def test_vfx_particle_info(self, runner, mock_unity_response): + """Test getting particle system info.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "particle", "info", "Fire"]) + assert result.exit_code == 0 + + def test_vfx_particle_play(self, runner, mock_unity_response): + """Test playing a particle system.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "particle", "play", "Fire"]) + assert result.exit_code == 0 + + def test_vfx_particle_stop(self, runner, mock_unity_response): + """Test stopping a particle system.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "particle", "stop", "Fire"]) + assert result.exit_code == 0 + + def test_vfx_line_info(self, runner, mock_unity_response): + """Test getting line renderer info.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "line", "info", "LaserBeam"]) + assert result.exit_code == 0 + + def test_vfx_line_create_line(self, runner, mock_unity_response): + """Test creating a line.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "line", "create-line", "Line", "--start", "0", "0", "0", "--end", "10", "5", "0"]) + assert result.exit_code == 0 + + def test_vfx_line_create_circle(self, runner, mock_unity_response): + """Test creating a circle.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "line", "create-circle", "Circle", "--radius", "5"]) + assert result.exit_code == 0 + + def test_vfx_trail_info(self, runner, mock_unity_response): + """Test getting trail renderer info.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "trail", "info", "Trail"]) + assert result.exit_code == 0 + + def test_vfx_trail_set_time(self, runner, mock_unity_response): + """Test setting trail time.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "trail", "set-time", "Trail", "2.0"]) + assert result.exit_code == 0 + + def test_vfx_raw(self, runner, mock_unity_response): + """Test raw VFX action.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "raw", "particle_set_main", "Fire", "--params", '{"duration": 5}']) + assert result.exit_code == 0 + + def test_vfx_raw_invalid_json(self, runner): + """Test raw VFX action with invalid JSON.""" + result = runner.invoke(cli, ["vfx", "raw", "particle_set_main", "Fire", "--params", "invalid json"]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + +# ============================================================================= +# Batch Command Tests +# ============================================================================= + +class TestBatchCommands: + """Tests for batch commands.""" + + def test_batch_inline(self, runner, mock_unity_response): + """Test inline batch execution.""" + batch_response = { + "success": True, + "data": {"results": [{"success": True}]} + } + with patch("cli.commands.batch.run_command", return_value=batch_response): + result = runner.invoke(cli, ["batch", "inline", '[{"tool": "manage_scene", "params": {"action": "get_active"}}]']) + assert result.exit_code == 0 + + def test_batch_inline_invalid_json(self, runner): + """Test inline batch with invalid JSON.""" + result = runner.invoke(cli, ["batch", "inline", "not valid json"]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + def test_batch_template(self, runner): + """Test generating batch template.""" + result = runner.invoke(cli, ["batch", "template"]) + assert result.exit_code == 0 + # Template should be valid JSON + import json + template = json.loads(result.output) + assert isinstance(template, list) + assert len(template) > 0 + assert "tool" in template[0] + + def test_batch_run_file(self, runner, tmp_path, mock_unity_response): + """Test running batch from file.""" + # Create a temp batch file + batch_file = tmp_path / "commands.json" + batch_file.write_text('[{"tool": "manage_scene", "params": {"action": "get_active"}}]') + + batch_response = { + "success": True, + "data": {"results": [{"success": True}]} + } + with patch("cli.commands.batch.run_command", return_value=batch_response): + result = runner.invoke(cli, ["batch", "run", str(batch_file)]) + assert result.exit_code == 0 + + +# ============================================================================= +# Enhanced Editor Command Tests +# ============================================================================= + +class TestEditorEnhancedCommands: + """Tests for new editor subcommands.""" + + def test_editor_refresh(self, runner, mock_unity_response): + """Test editor refresh.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "refresh"]) + assert result.exit_code == 0 + + def test_editor_refresh_with_compile(self, runner, mock_unity_response): + """Test editor refresh with compile flag.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "refresh", "--compile"]) + assert result.exit_code == 0 + + def test_editor_custom_tool(self, runner, mock_unity_response): + """Test executing custom tool.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "custom-tool", "MyTool"]) + assert result.exit_code == 0 + + def test_editor_custom_tool_with_params(self, runner, mock_unity_response): + """Test executing custom tool with parameters.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "custom-tool", "BuildTool", "--params", '{"target": "Android"}']) + assert result.exit_code == 0 + + def test_editor_custom_tool_invalid_json(self, runner): + """Test custom tool with invalid JSON params.""" + result = runner.invoke(cli, ["editor", "custom-tool", "MyTool", "--params", "bad json"]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + def test_editor_tests_async(self, runner): + """Test async test execution.""" + async_response = { + "success": True, + "data": {"job_id": "test-job-123", "status": "running"} + } + with patch("cli.commands.editor.run_command", return_value=async_response): + result = runner.invoke(cli, ["editor", "tests", "--async"]) + assert result.exit_code == 0 + assert "test-job-123" in result.output + + def test_editor_poll_test(self, runner): + """Test polling test job.""" + poll_response = { + "success": True, + "data": { + "job_id": "test-job-123", + "status": "succeeded", + "result": {"summary": {"total": 10, "passed": 10, "failed": 0}} + } + } + with patch("cli.commands.editor.run_command", return_value=poll_response): + result = runner.invoke(cli, ["editor", "poll-test", "test-job-123"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Code Search Tests +# ============================================================================= + +class TestCodeSearchCommand: + """Tests for code search command.""" + + def test_code_search(self, runner): + """Test code search.""" + # Mock manage_script response with file contents + read_response = { + "status": "success", + "result": { + "success": True, + "data": { + "contents": "using UnityEngine;\n\npublic class Player : MonoBehaviour\n{\n void Start() {}\n}\n", + "contentsEncoded": False, + } + } + } + with patch("cli.commands.code.run_command", return_value=read_response): + result = runner.invoke(cli, ["code", "search", "class.*Player", "Assets/Scripts/Player.cs"]) + assert result.exit_code == 0 + assert "Line 3" in result.output + assert "class Player" in result.output + + def test_code_search_no_matches(self, runner): + """Test code search with no matches.""" + read_response = { + "status": "success", + "result": { + "success": True, + "data": { + "contents": "using UnityEngine;\n\npublic class Test : MonoBehaviour {}\n", + "contentsEncoded": False, + } + } + } + with patch("cli.commands.code.run_command", return_value=read_response): + result = runner.invoke(cli, ["code", "search", "nonexistent", "Assets/Scripts/Test.cs"]) + assert result.exit_code == 0 + assert "No matches" in result.output + + def test_code_search_with_options(self, runner): + """Test code search with options.""" + read_response = { + "status": "success", + "result": { + "success": True, + "data": { + "contents": "// TODO: implement this\n// FIXME: bug here\nclass Test {}\n", + "contentsEncoded": False, + } + } + } + with patch("cli.commands.code.run_command", return_value=read_response): + result = runner.invoke(cli, ["code", "search", "TODO", "Assets/Utils.cs", "--max-results", "100", "--case-sensitive"]) + assert result.exit_code == 0 + assert "Line 1" in result.output + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/docs/CLI_USAGE.md b/docs/CLI_USAGE.md new file mode 100644 index 000000000..aa00949c0 --- /dev/null +++ b/docs/CLI_USAGE.md @@ -0,0 +1,393 @@ +# Unity MCP CLI Usage Guide + +The Unity MCP CLI provides command-line access to control Unity Editor through the Model Context Protocol. Now only support Local HTTP. + +Note: Some tools are still experimental and might fail under circumstances. Please submit an issue for us to make it better. + +## Installation + +```bash +cd Server +pip install -e . +# Or with uv: +uv pip install -e . +``` + +## Quick Start + +```bash +# Check connection +unity-mcp status + +# List Unity instances +unity-mcp instance list + +# Get scene hierarchy +unity-mcp scene hierarchy + +# Find a GameObject +unity-mcp gameobject find "Player" +``` + +## Global Options + +| Option | Env Variable | Description | +|--------|--------------|-------------| +| `-h, --host` | `UNITY_MCP_HOST` | Server host (default: 127.0.0.1) | +| `-p, --port` | `UNITY_MCP_HTTP_PORT` | Server port (default: 8080) | +| `-t, --timeout` | `UNITY_MCP_TIMEOUT` | Timeout in seconds (default: 30) | +| `-f, --format` | `UNITY_MCP_FORMAT` | Output format: text, json, table | +| `-i, --instance` | `UNITY_MCP_INSTANCE` | Target Unity instance | + +## Command Reference + +### Instance Management + +```bash +# List connected Unity instances +unity-mcp instance list + +# Set active instance +unity-mcp instance set "ProjectName@abc123" + +# Show current instance +unity-mcp instance current +``` + +### Scene Operations + +```bash +# Get scene hierarchy +unity-mcp scene hierarchy +unity-mcp scene hierarchy --limit 20 --depth 3 + +# Get active scene info +unity-mcp scene active + +# Load/save scenes +unity-mcp scene load "Assets/Scenes/Main.unity" +unity-mcp scene save + +# Take screenshot +unity-mcp scene screenshot --name "capture" +``` + +### GameObject Operations + +```bash +# Find GameObjects +unity-mcp gameobject find "Player" +unity-mcp gameobject find "Enemy" --method by_tag + +# Create GameObjects +unity-mcp gameobject create "NewCube" --primitive Cube +unity-mcp gameobject create "Empty" --position 0 5 0 + +# Modify GameObjects +unity-mcp gameobject modify "Cube" --position 1 2 3 --rotation 0 45 0 + +# Delete/duplicate +unity-mcp gameobject delete "OldObject" --force +unity-mcp gameobject duplicate "Template" +``` + +### Component Operations + +```bash +# Add component +unity-mcp component add "Player" Rigidbody + +# Remove component +unity-mcp component remove "Player" Rigidbody + +# Set property +unity-mcp component set "Player" Rigidbody mass 10 +``` + +### Script Operations + +```bash +# Create script +unity-mcp script create "PlayerController" --path "Assets/Scripts" + +# Read script +unity-mcp script read "Assets/Scripts/Player.cs" + +# Delete script +unity-mcp script delete "Assets/Scripts/Old.cs" --force +``` + +### Code Search + +```bash +# Search with regex +unity-mcp code search "class.*Player" "Assets/Scripts/Player.cs" +unity-mcp code search "TODO|FIXME" "Assets/Scripts/Utils.cs" +unity-mcp code search "void Update" "Assets/Scripts/Game.cs" --max-results 20 +``` + +### Shader Operations + +```bash +# Create shader +unity-mcp shader create "MyShader" --path "Assets/Shaders" + +# Read shader +unity-mcp shader read "Assets/Shaders/Custom.shader" + +# Update from file +unity-mcp shader update "Assets/Shaders/Custom.shader" --file local.shader + +# Delete shader +unity-mcp shader delete "Assets/Shaders/Old.shader" --force +``` + +### Editor Controls + +```bash +# Play mode +unity-mcp editor play +unity-mcp editor pause +unity-mcp editor stop + +# Refresh assets +unity-mcp editor refresh +unity-mcp editor refresh --compile + +# Console +unity-mcp editor console +unity-mcp editor console --clear + +# Tags and layers +unity-mcp editor add-tag "Enemy" +unity-mcp editor add-layer "Projectiles" + +# Menu items +unity-mcp editor menu "Edit/Project Settings..." + +# Custom tools +unity-mcp editor custom-tool "MyBuildTool" +unity-mcp editor custom-tool "Deploy" --params '{"target": "Android"}' +``` + +### Testing + +```bash +# Run tests synchronously +unity-mcp editor tests --mode EditMode + +# Run tests asynchronously +unity-mcp editor tests --mode PlayMode --async + +# Poll test job +unity-mcp editor poll-test +unity-mcp editor poll-test --wait 60 --details +``` + +### Material Operations + +```bash +# Create material +unity-mcp material create "Assets/Materials/Red.mat" + +# Set color +unity-mcp material set-color "Assets/Materials/Red.mat" 1 0 0 + +# Assign to object +unity-mcp material assign "Assets/Materials/Red.mat" "Cube" +``` + +### VFX Operations + +```bash +# Particle systems +unity-mcp vfx particle info "Fire" +unity-mcp vfx particle play "Fire" --with-children +unity-mcp vfx particle stop "Fire" + +# Line renderers +unity-mcp vfx line info "LaserBeam" +unity-mcp vfx line create-line "Line" --start 0 0 0 --end 10 5 0 +unity-mcp vfx line create-circle "Circle" --radius 5 + +# Trail renderers +unity-mcp vfx trail info "PlayerTrail" +unity-mcp vfx trail set-time "Trail" 2.0 + +# Raw VFX actions (access all 60+ actions) +unity-mcp vfx raw particle_set_main "Fire" --params '{"duration": 5}' +``` + +### Batch Operations + +```bash +# Execute from JSON file +unity-mcp batch run commands.json +unity-mcp batch run commands.json --parallel --fail-fast + +# Execute inline JSON +unity-mcp batch inline '[{"tool": "manage_scene", "params": {"action": "get_active"}}]' + +# Generate template +unity-mcp batch template > my_commands.json +``` + +### Prefab Operations + +```bash +# Open prefab for editing +unity-mcp prefab open "Assets/Prefabs/Player.prefab" + +# Save and close +unity-mcp prefab save +unity-mcp prefab close + +# Create from GameObject +unity-mcp prefab create "Player" --path "Assets/Prefabs" +``` + +### Asset Operations + +```bash +# Search assets +unity-mcp asset search --pattern "*.mat" --path "Assets/Materials" + +# Get asset info +unity-mcp asset info "Assets/Materials/Red.mat" + +# Create folder +unity-mcp asset mkdir "Assets/NewFolder" + +# Move/rename +unity-mcp asset move "Assets/Old.mat" "Assets/Materials/" +``` + +### Animation Operations + +```bash +# Play animation state +unity-mcp animation play "Player" "Run" + +# Set animator parameter +unity-mcp animation set-parameter "Player" Speed 1.5 +unity-mcp animation set-parameter "Player" IsRunning true +``` + +### Audio Operations + +```bash +# Play audio +unity-mcp audio play "AudioPlayer" + +# Stop audio +unity-mcp audio stop "AudioPlayer" + +# Set volume +unity-mcp audio volume "AudioPlayer" 0.5 +``` + +### Lighting Operations + +```bash +# Create light +unity-mcp lighting create "NewLight" --type Point --position 0 5 0 +unity-mcp lighting create "Spotlight" --type Spot --intensity 2 +``` + +### UI Operations + +```bash +# Create canvas +unity-mcp ui create-canvas "MainCanvas" + +# Create text +unity-mcp ui create-text "Title" --parent "MainCanvas" --text "Hello World" + +# Create button +unity-mcp ui create-button "StartBtn" --parent "MainCanvas" --text "Start" + +# Create image +unity-mcp ui create-image "Background" --parent "MainCanvas" +``` + +### Raw Commands + +For any MCP tool not covered by dedicated commands: + +```bash +unity-mcp raw manage_scene '{"action": "get_hierarchy", "max_nodes": 100}' +unity-mcp raw read_console '{"count": 20}' +``` + +--- + +## Complete Command Reference + +| Group | Subcommands | +|-------|-------------| +| `instance` | `list`, `set`, `current` | +| `scene` | `hierarchy`, `active`, `load`, `save`, `create`, `screenshot`, `build-settings` | +| `gameobject` | `find`, `create`, `modify`, `delete`, `duplicate`, `move` | +| `component` | `add`, `remove`, `set`, `modify` | +| `script` | `create`, `read`, `delete`, `edit`, `validate` | +| `code` | `read`, `search` | +| `shader` | `create`, `read`, `update`, `delete` | +| `editor` | `play`, `pause`, `stop`, `refresh`, `console`, `menu`, `tool`, `add-tag`, `remove-tag`, `add-layer`, `remove-layer`, `tests`, `poll-test`, `custom-tool` | +| `asset` | `search`, `info`, `create`, `delete`, `duplicate`, `move`, `rename`, `import`, `mkdir` | +| `prefab` | `open`, `close`, `save`, `create` | +| `material` | `info`, `create`, `set-color`, `set-property`, `assign`, `set-renderer-color` | +| `vfx particle` | `info`, `play`, `stop`, `pause`, `restart`, `clear` | +| `vfx line` | `info`, `set-positions`, `create-line`, `create-circle`, `clear` | +| `vfx trail` | `info`, `set-time`, `clear` | +| `vfx` | `raw` (access all 60+ actions) | +| `batch` | `run`, `inline`, `template` | +| `animation` | `play`, `set-parameter` | +| `audio` | `play`, `stop`, `volume` | +| `lighting` | `create` | +| `ui` | `create-canvas`, `create-text`, `create-button`, `create-image` | + +--- + +## Output Formats + +```bash +# Text (default) - human readable +unity-mcp scene hierarchy + +# JSON - for scripting +unity-mcp --format json scene hierarchy + +# Table - structured display +unity-mcp --format table instance list +``` + +## Environment Variables + +Set defaults via environment: + +```bash +export UNITY_MCP_HOST=192.168.1.100 +export UNITY_MCP_HTTP_PORT=8080 +export UNITY_MCP_FORMAT=json +export UNITY_MCP_INSTANCE=MyProject@abc123 +``` + +## Troubleshooting + +### Connection Issues + +```bash +# Check server status +unity-mcp status + +# Verify Unity is running with MCP plugin +# Check Unity console for MCP connection messages +``` + +### Common Errors + +| Error | Solution | +|-------|----------| +| Cannot connect to server | Ensure Unity MCP server is running | +| Unknown command type | Unity plugin may not support this tool | +| Timeout | Increase timeout with `-t 60` | From 3c4c4a6cd916b325052761e78d28d107d6255a6f Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 14:13:27 -0400 Subject: [PATCH 08/24] Log a message with implicit URI changes Small update for #542 --- .../Transport/Transports/WebSocketTransportClient.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs index 4588e4a60..65b4e4873 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs @@ -682,9 +682,14 @@ private static Uri BuildWebSocketUri(string baseUrl) throw new InvalidOperationException($"Invalid MCP base URL: {baseUrl}"); } - // Replace 0.0.0.0 with localhost for client connections - // 0.0.0.0 is only valid for server binding, not client connections - string host = httpUri.Host == "0.0.0.0" ? "localhost" : httpUri.Host; + // Replace bind-only addresses with localhost for client connections + // 0.0.0.0 and :: are only valid for server binding, not client connections + string host = httpUri.Host; + if (host == "0.0.0.0" || host == "::") + { + McpLog.Warn($"[WebSocket] Base URL host '{host}' is bind-only; using 'localhost' for client connection."); + host = "localhost"; + } var builder = new UriBuilder(httpUri) { From 198a1f1dfe4d41554dc56db686512fac2449f2b7 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 14:41:16 -0400 Subject: [PATCH 09/24] Minor fixes (#602) * Log a message with implicit URI changes Small update for #542 * Log a message with implicit URI changes Small update for #542 * Add helper scripts to update forks * fix: improve HTTP Local URL validation UX and styling specificity - Rename CSS class from generic "error" to "http-local-url-error" for better specificity - Rename "invalid-url" class to "http-local-invalid-url" for clarity - Disable httpServerCommandField when URL is invalid or transport not HTTP Local - Clear field value and tooltip when showing validation errors - Ensure field is re-enabled when URL becomes valid --- .../Editor/Windows/Components/Common.uss | 2 +- .../Connection/McpConnectionSection.cs | 15 +++++++++------ tools/update_fork.bat | 17 +++++++++++++++++ tools/update_fork.sh | 8 ++++++++ 4 files changed, 35 insertions(+), 7 deletions(-) create mode 100755 tools/update_fork.bat create mode 100755 tools/update_fork.sh diff --git a/MCPForUnity/Editor/Windows/Components/Common.uss b/MCPForUnity/Editor/Windows/Components/Common.uss index 5aa6e988c..fdaa7001f 100644 --- a/MCPForUnity/Editor/Windows/Components/Common.uss +++ b/MCPForUnity/Editor/Windows/Components/Common.uss @@ -437,7 +437,7 @@ margin-bottom: 4px; } -.help-text.error { +.help-text.http-local-url-error { color: rgba(255, 80, 80, 1); -unity-font-style: bold; } diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index 782ed7b9f..f54dcb8f6 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -420,6 +420,7 @@ public void UpdateHttpServerCommandDisplay() httpServerCommandSection.style.display = DisplayStyle.None; httpServerCommandField.value = string.Empty; httpServerCommandField.tooltip = string.Empty; + httpServerCommandField.SetEnabled(false); if (httpServerCommandHint != null) { httpServerCommandHint.text = string.Empty; @@ -435,22 +436,24 @@ public void UpdateHttpServerCommandDisplay() if (!isLocalHttpUrl) { - httpServerCommandField.value = ""; - httpServerCommandField.tooltip = "The command cannot be generated because the URL is not a local address."; - httpServerCommandSection.EnableInClassList("invalid-url", true); + httpServerCommandField.value = string.Empty; + httpServerCommandField.tooltip = string.Empty; + httpServerCommandField.SetEnabled(false); + httpServerCommandSection.EnableInClassList("http-local-invalid-url", true); if (httpServerCommandHint != null) { httpServerCommandHint.text = "⚠ HTTP Local requires a localhost URL (localhost/127.0.0.1/0.0.0.0/::1)."; - httpServerCommandHint.AddToClassList("error"); + httpServerCommandHint.AddToClassList("http-local-url-error"); } copyHttpServerCommandButton?.SetEnabled(false); return; } - httpServerCommandSection.EnableInClassList("invalid-url", false); + httpServerCommandSection.EnableInClassList("http-local-invalid-url", false); + httpServerCommandField.SetEnabled(true); if (httpServerCommandHint != null) { - httpServerCommandHint.RemoveFromClassList("error"); + httpServerCommandHint.RemoveFromClassList("http-local-url-error"); } if (MCPServiceLocator.Server.TryGetLocalHttpServerCommand(out var command, out var error)) diff --git a/tools/update_fork.bat b/tools/update_fork.bat new file mode 100755 index 000000000..db70dd242 --- /dev/null +++ b/tools/update_fork.bat @@ -0,0 +1,17 @@ +@echo off +setlocal + +git checkout main +if errorlevel 1 exit /b 1 + +git fetch -ap upstream +if errorlevel 1 exit /b 1 + +git fetch -ap +if errorlevel 1 exit /b 1 + +git rebase upstream/main +if errorlevel 1 exit /b 1 + +git push +if errorlevel 1 exit /b 1 diff --git a/tools/update_fork.sh b/tools/update_fork.sh new file mode 100755 index 000000000..dab783b06 --- /dev/null +++ b/tools/update_fork.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +git checkout main +git fetch -ap upstream +git fetch -ap +git rebase upstream/main +git push From 81ba9fb23f48d2a0ee5edb01b5f02fb66241684f Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 16:00:11 -0400 Subject: [PATCH 10/24] Docker mcp gateway (#603) * Log a message with implicit URI changes Small update for #542 * Update docker container to default to stdio Replaces #541 --- Server/Dockerfile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Server/Dockerfile b/Server/Dockerfile index 60453c6aa..202e2397a 100644 --- a/Server/Dockerfile +++ b/Server/Dockerfile @@ -28,4 +28,9 @@ EXPOSE 8080 ENV PYTHONPATH=/app/Server/src -CMD ["uv", "run", "python", "src/main.py", "--transport", "http", "--http-host", "0.0.0.0", "--http-port", "8080"] +# ENTRYPOINT allows override via docker run arguments +# Default: stdio transport (Docker MCP Gateway compatible) +# For HTTP: docker run -p 8080:8080 --transport http --http-host 0.0.0.0 --http-port 8080 +# If hosting remotely, you should add the --project-scoped-tools flag +ENTRYPOINT ["uv", "run", "mcp-for-unity"] +CMD [] From 7e865c3ea20c53151130c612ef1f5a21c715fa0e Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 21 Jan 2026 13:02:13 -0800 Subject: [PATCH 11/24] fix: Rider config path and add MCP registry manifest (#604) - Fix RiderConfigurator to use correct GitHub Copilot config path: - Windows: %LOCALAPPDATA%\github-copilot\intellij\mcp.json - macOS: ~/Library/Application Support/github-copilot/intellij/mcp.json - Linux: ~/.config/github-copilot/intellij/mcp.json - Add mcp.json for GitHub MCP Registry support: - Enables users to install via coplaydev/unity-mcp - Uses uvx with mcpforunityserver from PyPI --- .../Clients/Configurators/RiderConfigurator.cs | 6 +++--- mcp.json | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 mcp.json diff --git a/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs index 5db02acaf..2558a4263 100644 --- a/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs @@ -10,9 +10,9 @@ public class RiderConfigurator : JsonFileMcpConfigurator public RiderConfigurator() : base(new McpClient { name = "Rider GitHub Copilot", - windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "JetBrains", "Rider", "mcp.json"), - macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "JetBrains", "Rider", "mcp.json"), - linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "JetBrains", "Rider", "mcp.json"), + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "github-copilot", "intellij", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "github-copilot", "intellij", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "github-copilot", "intellij", "mcp.json"), IsVsCodeLayout = true }) { } diff --git a/mcp.json b/mcp.json new file mode 100644 index 000000000..fba334e61 --- /dev/null +++ b/mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "unity-mcp": { + "type": "stdio", + "command": "uvx", + "args": [ + "--from", + "mcpforunityserver", + "mcp-for-unity" + ] + } + } +} From 79b3482ae04bba238625e756a3c37f7accd13b95 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 17:17:37 -0400 Subject: [PATCH 12/24] Use click.echo instead of print statements --- Server/src/cli/utils/output.py | 71 +++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/Server/src/cli/utils/output.py b/Server/src/cli/utils/output.py index ad43493fa..b7dc2d69e 100644 --- a/Server/src/cli/utils/output.py +++ b/Server/src/cli/utils/output.py @@ -1,17 +1,18 @@ """Output formatting utilities for CLI.""" import json -import sys -from typing import Any, Dict, List, Optional, Union +from typing import Any + +import click def format_output(data: Any, format_type: str = "text") -> str: """Format output based on requested format type. - + Args: data: Data to format format_type: One of 'text', 'json', 'table' - + Returns: Formatted string """ @@ -34,22 +35,22 @@ def format_as_json(data: Any) -> str: def format_as_text(data: Any, indent: int = 0) -> str: """Format data as human-readable text.""" prefix = " " * indent - + if data is None: return f"{prefix}(none)" - + if isinstance(data, dict): # Check for error response if "success" in data and not data.get("success"): error = data.get("error") or data.get("message") or "Unknown error" return f"{prefix}❌ Error: {error}" - + # Check for success response with data if "success" in data and data.get("success"): result = data.get("data") or data.get("result") or data if result != data: return format_as_text(result, indent) - + lines = [] for key, value in data.items(): if key in ("success", "error", "message") and "success" in data: @@ -61,17 +62,20 @@ def format_as_text(data: Any, indent: int = 0) -> str: lines.append(f"{prefix}{key}: [{len(value)} items]") if len(value) <= 10: for i, item in enumerate(value): - lines.append(f"{prefix} [{i}] {_format_list_item(item)}") + lines.append( + f"{prefix} [{i}] {_format_list_item(item)}") else: for i, item in enumerate(value[:5]): - lines.append(f"{prefix} [{i}] {_format_list_item(item)}") + lines.append( + f"{prefix} [{i}] {_format_list_item(item)}") lines.append(f"{prefix} ... ({len(value) - 10} more)") for i, item in enumerate(value[-5:], len(value) - 5): - lines.append(f"{prefix} [{i}] {_format_list_item(item)}") + lines.append( + f"{prefix} [{i}] {_format_list_item(item)}") else: lines.append(f"{prefix}{key}: {value}") return "\n".join(lines) - + if isinstance(data, list): if not data: return f"{prefix}(empty list)" @@ -81,7 +85,7 @@ def format_as_text(data: Any, indent: int = 0) -> str: if len(data) > 20: lines.append(f"{prefix} ... ({len(data) - 20} more)") return "\n".join(lines) - + return f"{prefix}{data}" @@ -89,7 +93,8 @@ def _format_list_item(item: Any) -> str: """Format a single list item.""" if isinstance(item, dict): # Try to find a name/id field for display - name = item.get("name") or item.get("Name") or item.get("id") or item.get("Id") + name = item.get("name") or item.get( + "Name") or item.get("id") or item.get("Id") if name: extra = "" if "instanceID" in item: @@ -107,25 +112,26 @@ def format_as_table(data: Any) -> str: if isinstance(data, dict): # Check for success response with data if "success" in data and data.get("success"): - result = data.get("data") or data.get("result") or data.get("items") + result = data.get("data") or data.get( + "result") or data.get("items") if isinstance(result, list): return _build_table(result) - + # Single dict as key-value table rows = [[str(k), str(v)[:60]] for k, v in data.items()] return _build_table(rows, headers=["Key", "Value"]) - + if isinstance(data, list): return _build_table(data) - + return str(data) -def _build_table(data: List[Any], headers: Optional[List[str]] = None) -> str: +def _build_table(data: list[Any], headers: list[str] | None = None) -> str: """Build an ASCII table from list data.""" if not data: return "(no data)" - + # Convert list of dicts to rows if isinstance(data[0], dict): if headers is None: @@ -138,51 +144,52 @@ def _build_table(data: List[Any], headers: Optional[List[str]] = None) -> str: else: rows = [[str(item)[:60]] for item in data] headers = headers or ["Value"] - + # Calculate column widths col_widths = [len(h) for h in headers] for row in rows: for i, cell in enumerate(row): if i < len(col_widths): col_widths[i] = max(col_widths[i], len(cell)) - + # Build table lines = [] - + # Header - header_line = " | ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers)) + header_line = " | ".join( + h.ljust(col_widths[i]) for i, h in enumerate(headers)) lines.append(header_line) lines.append("-+-".join("-" * w for w in col_widths)) - + # Rows for row in rows[:50]: # Limit rows row_line = " | ".join( - (row[i] if i < len(row) else "").ljust(col_widths[i]) + (row[i] if i < len(row) else "").ljust(col_widths[i]) for i in range(len(headers)) ) lines.append(row_line) - + if len(rows) > 50: lines.append(f"... ({len(rows) - 50} more rows)") - + return "\n".join(lines) def print_success(message: str) -> None: """Print a success message.""" - print(f"✅ {message}") + click.echo(f"✅ {message}") def print_error(message: str) -> None: """Print an error message to stderr.""" - print(f"❌ {message}", file=sys.stderr) + click.echo(f"❌ {message}", err=True) def print_warning(message: str) -> None: """Print a warning message.""" - print(f"⚠️ {message}") + click.echo(f"⚠️ {message}") def print_info(message: str) -> None: """Print an info message.""" - print(f"ℹ️ {message}") + click.echo(f"ℹ️ {message}") From ac8189569c819605617f7dc4be9385352fd95fc2 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 17:24:37 -0400 Subject: [PATCH 13/24] Standardize whitespace --- Server/src/cli/commands/animation.py | 13 +- Server/src/cli/commands/asset.py | 65 ++++---- Server/src/cli/commands/audio.py | 24 +-- Server/src/cli/commands/batch.py | 45 +++--- Server/src/cli/commands/code.py | 57 +++---- Server/src/cli/commands/component.py | 38 ++--- Server/src/cli/commands/editor.py | 90 +++++------ Server/src/cli/commands/gameobject.py | 73 ++++----- Server/src/cli/commands/instance.py | 28 ++-- Server/src/cli/commands/lighting.py | 46 +++--- Server/src/cli/commands/material.py | 48 +++--- Server/src/cli/commands/prefab.py | 27 ++-- Server/src/cli/commands/scene.py | 41 ++--- Server/src/cli/commands/script.py | 41 ++--- Server/src/cli/commands/shader.py | 33 +++-- Server/src/cli/commands/ui.py | 44 +++--- Server/src/cli/commands/vfx.py | 60 ++++---- Server/src/cli/main.py | 65 ++++---- Server/src/cli/utils/config.py | 10 +- Server/src/cli/utils/connection.py | 41 ++--- .../src/transport/legacy/unity_connection.py | 6 +- Server/tests/test_cli.py | 140 ++++++++++++------ prune_tool_results.py | 51 ++++--- 23 files changed, 589 insertions(+), 497 deletions(-) diff --git a/Server/src/cli/commands/animation.py b/Server/src/cli/commands/animation.py index 19de4b2b5..4419105a8 100644 --- a/Server/src/cli/commands/animation.py +++ b/Server/src/cli/commands/animation.py @@ -32,14 +32,14 @@ def animation(): ) def play(target: str, state_name: str, layer: int, search_method: Optional[str]): """Play an animation state on a target's Animator. - + \b Examples: unity-mcp animation play "Player" "Walk" unity-mcp animation play "Enemy" "Attack" --layer 1 """ config = get_config() - + # Set Animator parameter to trigger state params: dict[str, Any] = { "action": "set_property", @@ -49,10 +49,10 @@ def play(target: str, state_name: str, layer: int, search_method: Optional[str]) "value": state_name, "layer": layer, } - + if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_components", params, config) click.echo(format_output(result, config.format)) @@ -74,7 +74,7 @@ def play(target: str, state_name: str, layer: int, search_method: Optional[str]) ) def set_parameter(target: str, param_name: str, value: str, param_type: str): """Set an Animator parameter. - + \b Examples: unity-mcp animation set-parameter "Player" "Speed" 5.0 @@ -82,5 +82,6 @@ def set_parameter(target: str, param_name: str, value: str, param_type: str): unity-mcp animation set-parameter "Player" "Jump" "" --type trigger """ config = get_config() - print_info("Animation parameter command - requires custom Unity implementation") + print_info( + "Animation parameter command - requires custom Unity implementation") click.echo(f"Would set {param_name}={value} ({param_type}) on {target}") diff --git a/Server/src/cli/commands/asset.py b/Server/src/cli/commands/asset.py index b807bc4ca..8eba313f1 100644 --- a/Server/src/cli/commands/asset.py +++ b/Server/src/cli/commands/asset.py @@ -43,7 +43,7 @@ def asset(): ) def search(pattern: str, path: str, filter_type: Optional[str], limit: int, page: int): """Search for assets. - + \b Examples: unity-mcp asset search "*.prefab" @@ -52,7 +52,7 @@ def search(pattern: str, path: str, filter_type: Optional[str], limit: int, page unity-mcp asset search "t:MonoScript" --path "Assets/Scripts" """ config = get_config() - + params: dict[str, Any] = { "action": "search", "path": path, @@ -60,10 +60,10 @@ def search(pattern: str, path: str, filter_type: Optional[str], limit: int, page "pageSize": limit, "pageNumber": page, } - + if filter_type: params["filterType"] = filter_type - + try: result = run_command("manage_asset", params, config) click.echo(format_output(result, config.format)) @@ -81,20 +81,20 @@ def search(pattern: str, path: str, filter_type: Optional[str], limit: int, page ) def info(path: str, preview: bool): """Get detailed information about an asset. - + \b Examples: unity-mcp asset info "Assets/Materials/Red.mat" unity-mcp asset info "Assets/Prefabs/Player.prefab" --preview """ config = get_config() - + params: dict[str, Any] = { "action": "get_info", "path": path, "generatePreview": preview, } - + try: result = run_command("manage_asset", params, config) click.echo(format_output(result, config.format)) @@ -113,7 +113,7 @@ def info(path: str, preview: bool): ) def create(path: str, asset_type: str, properties: Optional[str]): """Create a new asset. - + \b Examples: unity-mcp asset create "Assets/Materials/Blue.mat" Material @@ -121,20 +121,20 @@ def create(path: str, asset_type: str, properties: Optional[str]): unity-mcp asset create "Assets/Materials/Custom.mat" Material --properties '{"color": [0,0,1,1]}' """ config = get_config() - + params: dict[str, Any] = { "action": "create", "path": path, "assetType": asset_type, } - + if properties: try: params["properties"] = json.loads(properties) except json.JSONDecodeError as e: print_error(f"Invalid JSON for properties: {e}") sys.exit(1) - + try: result = run_command("manage_asset", params, config) click.echo(format_output(result, config.format)) @@ -154,19 +154,20 @@ def create(path: str, asset_type: str, properties: Optional[str]): ) def delete(path: str, force: bool): """Delete an asset. - + \b Examples: unity-mcp asset delete "Assets/OldMaterial.mat" unity-mcp asset delete "Assets/Unused" --force """ config = get_config() - + if not force: click.confirm(f"Delete asset '{path}'?", abort=True) - + try: - result = run_command("manage_asset", {"action": "delete", "path": path}, config) + result = run_command( + "manage_asset", {"action": "delete", "path": path}, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success(f"Deleted: {path}") @@ -180,19 +181,19 @@ def delete(path: str, force: bool): @click.argument("destination") def duplicate(source: str, destination: str): """Duplicate an asset. - + \b Examples: unity-mcp asset duplicate "Assets/Materials/Red.mat" "Assets/Materials/RedCopy.mat" """ config = get_config() - + params: dict[str, Any] = { "action": "duplicate", "path": source, "destination": destination, } - + try: result = run_command("manage_asset", params, config) click.echo(format_output(result, config.format)) @@ -208,19 +209,19 @@ def duplicate(source: str, destination: str): @click.argument("destination") def move(source: str, destination: str): """Move an asset to a new location. - + \b Examples: unity-mcp asset move "Assets/Old/Material.mat" "Assets/New/Material.mat" """ config = get_config() - + params: dict[str, Any] = { "action": "move", "path": source, "destination": destination, } - + try: result = run_command("manage_asset", params, config) click.echo(format_output(result, config.format)) @@ -236,24 +237,24 @@ def move(source: str, destination: str): @click.argument("new_name") def rename(path: str, new_name: str): """Rename an asset. - + \b Examples: unity-mcp asset rename "Assets/Materials/Old.mat" "New.mat" """ config = get_config() - + # Construct destination path import os dir_path = os.path.dirname(path) destination = os.path.join(dir_path, new_name).replace("\\", "/") - + params: dict[str, Any] = { "action": "rename", "path": path, "destination": destination, } - + try: result = run_command("manage_asset", params, config) click.echo(format_output(result, config.format)) @@ -268,15 +269,16 @@ def rename(path: str, new_name: str): @click.argument("path") def import_asset(path: str): """Import/reimport an asset. - + \b Examples: unity-mcp asset import "Assets/Textures/NewTexture.png" """ config = get_config() - + try: - result = run_command("manage_asset", {"action": "import", "path": path}, config) + result = run_command( + "manage_asset", {"action": "import", "path": path}, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success(f"Imported: {path}") @@ -289,16 +291,17 @@ def import_asset(path: str): @click.argument("path") def mkdir(path: str): """Create a folder. - + \b Examples: unity-mcp asset mkdir "Assets/NewFolder" unity-mcp asset mkdir "Assets/Levels/Chapter1" """ config = get_config() - + try: - result = run_command("manage_asset", {"action": "create_folder", "path": path}, config) + result = run_command( + "manage_asset", {"action": "create_folder", "path": path}, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success(f"Created folder: {path}") diff --git a/Server/src/cli/commands/audio.py b/Server/src/cli/commands/audio.py index 7ee472278..3c50d172e 100644 --- a/Server/src/cli/commands/audio.py +++ b/Server/src/cli/commands/audio.py @@ -30,14 +30,14 @@ def audio(): ) def play(target: str, clip: Optional[str], search_method: Optional[str]): """Play audio on a target's AudioSource. - + \b Examples: unity-mcp audio play "MusicPlayer" unity-mcp audio play "SFXSource" --clip "Assets/Audio/explosion.wav" """ config = get_config() - + params: dict[str, Any] = { "action": "set_property", "target": target, @@ -48,10 +48,10 @@ def play(target: str, clip: Optional[str], search_method: Optional[str]): if clip: params["clip"] = clip - + if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_components", params, config) click.echo(format_output(result, config.format)) @@ -70,13 +70,13 @@ def play(target: str, clip: Optional[str], search_method: Optional[str]): ) def stop(target: str, search_method: Optional[str]): """Stop audio on a target's AudioSource. - + \b Examples: unity-mcp audio stop "MusicPlayer" """ config = get_config() - + params: dict[str, Any] = { "action": "set_property", "target": target, @@ -84,10 +84,10 @@ def stop(target: str, search_method: Optional[str]): "property": "Stop", "value": True, } - + if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_components", params, config) click.echo(format_output(result, config.format)) @@ -107,13 +107,13 @@ def stop(target: str, search_method: Optional[str]): ) def volume(target: str, level: float, search_method: Optional[str]): """Set audio volume on a target's AudioSource. - + \b Examples: unity-mcp audio volume "MusicPlayer" 0.5 """ config = get_config() - + params: dict[str, Any] = { "action": "set_property", "target": target, @@ -121,10 +121,10 @@ def volume(target: str, level: float, search_method: Optional[str]): "property": "volume", "value": level, } - + if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_components", params, config) click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/commands/batch.py b/Server/src/cli/commands/batch.py index dc8da5091..436ae2853 100644 --- a/Server/src/cli/commands/batch.py +++ b/Server/src/cli/commands/batch.py @@ -22,9 +22,9 @@ def batch(): @click.option("--fail-fast", is_flag=True, help="Stop on first failure.") def batch_run(file: str, parallel: bool, fail_fast: bool): """Execute commands from a JSON file. - + The JSON file should contain an array of command objects with 'tool' and 'params' keys. - + \\b File format: [ @@ -32,7 +32,7 @@ def batch_run(file: str, parallel: bool, fail_fast: bool): {"tool": "manage_gameobject", "params": {"action": "create", "name": "Cube2"}}, {"tool": "manage_components", "params": {"action": "add", "target": "Cube1", "componentType": "Rigidbody"}} ] - + \\b Examples: unity-mcp batch run commands.json @@ -40,7 +40,7 @@ def batch_run(file: str, parallel: bool, fail_fast: bool): unity-mcp batch run critical.json --fail-fast """ config = get_config() - + try: with open(file, 'r') as f: commands = json.load(f) @@ -50,34 +50,35 @@ def batch_run(file: str, parallel: bool, fail_fast: bool): except IOError as e: print_error(f"Error reading file: {e}") sys.exit(1) - + if not isinstance(commands, list): print_error("JSON file must contain an array of commands") sys.exit(1) - + if len(commands) > 25: print_error(f"Maximum 25 commands per batch, got {len(commands)}") sys.exit(1) - + params: dict[str, Any] = {"commands": commands} if parallel: params["parallel"] = True if fail_fast: params["failFast"] = True - + click.echo(f"Executing {len(commands)} commands...") - + try: result = run_command("batch_execute", params, config) click.echo(format_output(result, config.format)) - + if isinstance(result, dict): results = result.get("data", {}).get("results", []) succeeded = sum(1 for r in results if r.get("success")) failed = len(results) - succeeded - + if failed == 0: - print_success(f"All {succeeded} commands completed successfully") + print_success( + f"All {succeeded} commands completed successfully") else: print_info(f"{succeeded} succeeded, {failed} failed") except UnityConnectionError as e: @@ -91,38 +92,38 @@ def batch_run(file: str, parallel: bool, fail_fast: bool): @click.option("--fail-fast", is_flag=True, help="Stop on first failure.") def batch_inline(commands_json: str, parallel: bool, fail_fast: bool): """Execute commands from inline JSON. - + \\b Examples: unity-mcp batch inline '[{"tool": "manage_scene", "params": {"action": "get_active"}}]' - + unity-mcp batch inline '[ {"tool": "manage_gameobject", "params": {"action": "create", "name": "A", "primitiveType": "Cube"}}, {"tool": "manage_gameobject", "params": {"action": "create", "name": "B", "primitiveType": "Sphere"}} ]' """ config = get_config() - + try: commands = json.loads(commands_json) except json.JSONDecodeError as e: print_error(f"Invalid JSON: {e}") sys.exit(1) - + if not isinstance(commands, list): print_error("Commands must be an array") sys.exit(1) - + if len(commands) > 25: print_error(f"Maximum 25 commands per batch, got {len(commands)}") sys.exit(1) - + params: dict[str, Any] = {"commands": commands} if parallel: params["parallel"] = True if fail_fast: params["failFast"] = True - + try: result = run_command("batch_execute", params, config) click.echo(format_output(result, config.format)) @@ -135,7 +136,7 @@ def batch_inline(commands_json: str, parallel: bool, fail_fast: bool): @click.option("--output", "-o", type=click.Path(), help="Output file (default: stdout)") def batch_template(output: Optional[str]): """Generate a sample batch commands file. - + \\b Examples: unity-mcp batch template > commands.json @@ -172,9 +173,9 @@ def batch_template(output: Optional[str]): } } ] - + json_output = json.dumps(template, indent=2) - + if output: with open(output, 'w') as f: f.write(json_output) diff --git a/Server/src/cli/commands/code.py b/Server/src/cli/commands/code.py index cf5e6c9f9..7f07bc162 100644 --- a/Server/src/cli/commands/code.py +++ b/Server/src/cli/commands/code.py @@ -32,30 +32,30 @@ def code(): ) def read(path: str, start_line: Optional[int], line_count: Optional[int]): """Read a source file. - + \b Examples: unity-mcp code read "Assets/Scripts/Player.cs" unity-mcp code read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20 """ config = get_config() - + # Extract name and directory from path parts = path.replace("\\", "/").split("/") filename = os.path.splitext(parts[-1])[0] directory = "/".join(parts[:-1]) or "Assets" - + params: dict[str, Any] = { "action": "read", "name": filename, "path": directory, } - + if start_line: params["startLine"] = start_line if line_count: params["lineCount"] = line_count - + try: result = run_command("manage_script", params, config) # For read, output content directly if available @@ -88,10 +88,10 @@ def read(path: str, start_line: Optional[int], line_count: Optional[int]): ) def search(pattern: str, path: str, max_results: int, case_sensitive: bool): """Search for patterns in Unity scripts using regex. - + PATTERN is a regex pattern to search for. PATH is the script path (e.g., Assets/Scripts/Player.cs). - + \\b Examples: unity-mcp code search "class.*Player" "Assets/Scripts/Player.cs" @@ -100,89 +100,90 @@ def search(pattern: str, path: str, max_results: int, case_sensitive: bool): """ import re import base64 - + config = get_config() - + # Extract name and directory from path parts = path.replace("\\", "/").split("/") filename = os.path.splitext(parts[-1])[0] directory = "/".join(parts[:-1]) or "Assets" - + # Step 1: Read the file via Unity's manage_script read_params: dict[str, Any] = { "action": "read", "name": filename, "path": directory, } - + try: result = run_command("manage_script", read_params, config) - + # Handle nested response structure: {status, result: {success, data}} inner_result = result.get("result", result) - + if not inner_result.get("success") and result.get("status") != "success": click.echo(format_output(result, config.format)) return - + # Get file contents from nested data data = inner_result.get("data", {}) contents = data.get("contents") - + # Handle base64 encoded content if not contents and data.get("contentsEncoded") and data.get("encodedContents"): try: - contents = base64.b64decode(data["encodedContents"]).decode("utf-8", "replace") + contents = base64.b64decode( + data["encodedContents"]).decode("utf-8", "replace") except (ValueError, TypeError): pass - + if not contents: print_error(f"Could not read file content from {path}") sys.exit(1) - + # Step 2: Perform regex search locally flags = re.MULTILINE if not case_sensitive: flags |= re.IGNORECASE - + try: regex = re.compile(pattern, flags) except re.error as e: print_error(f"Invalid regex pattern: {e}") sys.exit(1) - + found = list(regex.finditer(contents)) - + if not found: print_info(f"No matches found for pattern: {pattern}") return - + results = [] for m in found[:max_results]: start_idx = m.start() - + # Calculate line number line_num = contents.count('\n', 0, start_idx) + 1 - + # Get line content line_start = contents.rfind('\n', 0, start_idx) + 1 line_end = contents.find('\n', start_idx) if line_end == -1: line_end = len(contents) - + line_content = contents[line_start:line_end].strip() - + results.append({ "line": line_num, "content": line_content, "match": m.group(0), }) - + # Display results click.echo(f"Found {len(results)} matches (total: {len(found)}):\n") for match in results: click.echo(f" Line {match['line']}: {match['content']}") - + except UnityConnectionError as e: print_error(str(e)) sys.exit(1) diff --git a/Server/src/cli/commands/component.py b/Server/src/cli/commands/component.py index 13e53940b..51b4492fe 100644 --- a/Server/src/cli/commands/component.py +++ b/Server/src/cli/commands/component.py @@ -32,7 +32,7 @@ def component(): ) def add(target: str, component_type: str, search_method: Optional[str], properties: Optional[str]): """Add a component to a GameObject. - + \b Examples: unity-mcp component add "Player" Rigidbody @@ -40,13 +40,13 @@ def add(target: str, component_type: str, search_method: Optional[str], properti unity-mcp component add "Enemy" Rigidbody --properties '{"mass": 5.0, "useGravity": true}' """ config = get_config() - + params: dict[str, Any] = { "action": "add", "target": target, "componentType": component_type, } - + if search_method: params["searchMethod"] = search_method if properties: @@ -55,7 +55,7 @@ def add(target: str, component_type: str, search_method: Optional[str], properti except json.JSONDecodeError as e: print_error(f"Invalid JSON for properties: {e}") sys.exit(1) - + try: result = run_command("manage_components", params, config) click.echo(format_output(result, config.format)) @@ -82,26 +82,26 @@ def add(target: str, component_type: str, search_method: Optional[str], properti ) def remove(target: str, component_type: str, search_method: Optional[str], force: bool): """Remove a component from a GameObject. - + \b Examples: unity-mcp component remove "Player" Rigidbody unity-mcp component remove "-81840" BoxCollider --search-method by_id --force """ config = get_config() - + if not force: click.confirm(f"Remove {component_type} from '{target}'?", abort=True) - + params: dict[str, Any] = { "action": "remove", "target": target, "componentType": component_type, } - + if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_components", params, config) click.echo(format_output(result, config.format)) @@ -125,7 +125,7 @@ def remove(target: str, component_type: str, search_method: Optional[str], force ) def set_property(target: str, component_type: str, property_name: str, value: str, search_method: Optional[str]): """Set a single property on a component. - + \b Examples: unity-mcp component set "Player" Rigidbody mass 5.0 @@ -133,14 +133,14 @@ def set_property(target: str, component_type: str, property_name: str, value: st unity-mcp component set "-81840" Light intensity 2.5 --search-method by_id """ config = get_config() - + # Try to parse value as JSON for complex types try: parsed_value = json.loads(value) except json.JSONDecodeError: # Keep as string if not valid JSON parsed_value = value - + params: dict[str, Any] = { "action": "set_property", "target": target, @@ -148,10 +148,10 @@ def set_property(target: str, component_type: str, property_name: str, value: st "property": property_name, "value": parsed_value, } - + if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_components", params, config) click.echo(format_output(result, config.format)) @@ -178,30 +178,30 @@ def set_property(target: str, component_type: str, property_name: str, value: st ) def modify(target: str, component_type: str, properties: str, search_method: Optional[str]): """Set multiple properties on a component at once. - + \b Examples: unity-mcp component modify "Player" Rigidbody --properties '{"mass": 5.0, "useGravity": false}' unity-mcp component modify "Light" Light --properties '{"intensity": 2.0, "color": [1, 0, 0, 1]}' """ config = get_config() - + try: props_dict = json.loads(properties) except json.JSONDecodeError as e: print_error(f"Invalid JSON for properties: {e}") sys.exit(1) - + params: dict[str, Any] = { "action": "set_property", "target": target, "componentType": component_type, "properties": props_dict, } - + if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_components", params, config) click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index c6bb0f71d..bdee3cfd0 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -19,7 +19,7 @@ def editor(): def play(): """Enter play mode.""" config = get_config() - + try: result = run_command("manage_editor", {"action": "play"}, config) click.echo(format_output(result, config.format)) @@ -34,7 +34,7 @@ def play(): def pause(): """Pause play mode.""" config = get_config() - + try: result = run_command("manage_editor", {"action": "pause"}, config) click.echo(format_output(result, config.format)) @@ -49,7 +49,7 @@ def pause(): def stop(): """Stop play mode.""" config = get_config() - + try: result = run_command("manage_editor", {"action": "stop"}, config) click.echo(format_output(result, config.format)) @@ -93,7 +93,7 @@ def stop(): ) def console(log_types: tuple, count: int, filter_text: Optional[str], stacktrace: bool, clear: bool): """Read or clear the Unity console. - + \b Examples: unity-mcp editor console @@ -102,7 +102,7 @@ def console(log_types: tuple, count: int, filter_text: Optional[str], stacktrace unity-mcp editor console --clear """ config = get_config() - + if clear: try: result = run_command("read_console", {"action": "clear"}, config) @@ -113,17 +113,17 @@ def console(log_types: tuple, count: int, filter_text: Optional[str], stacktrace print_error(str(e)) sys.exit(1) return - + params: dict[str, Any] = { "action": "get", "types": list(log_types), "count": count, "include_stacktrace": stacktrace, } - + if filter_text: params["filter_text"] = filter_text - + try: result = run_command("read_console", params, config) click.echo(format_output(result, config.format)) @@ -136,16 +136,17 @@ def console(log_types: tuple, count: int, filter_text: Optional[str], stacktrace @click.argument("tag_name") def add_tag(tag_name: str): """Add a new tag. - + \b Examples: unity-mcp editor add-tag "Enemy" unity-mcp editor add-tag "Collectible" """ config = get_config() - + try: - result = run_command("manage_editor", {"action": "add_tag", "tagName": tag_name}, config) + result = run_command( + "manage_editor", {"action": "add_tag", "tagName": tag_name}, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success(f"Added tag: {tag_name}") @@ -158,15 +159,16 @@ def add_tag(tag_name: str): @click.argument("tag_name") def remove_tag(tag_name: str): """Remove a tag. - + \b Examples: unity-mcp editor remove-tag "OldTag" """ config = get_config() - + try: - result = run_command("manage_editor", {"action": "remove_tag", "tagName": tag_name}, config) + result = run_command( + "manage_editor", {"action": "remove_tag", "tagName": tag_name}, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success(f"Removed tag: {tag_name}") @@ -179,15 +181,16 @@ def remove_tag(tag_name: str): @click.argument("layer_name") def add_layer(layer_name: str): """Add a new layer. - + \b Examples: unity-mcp editor add-layer "Interactable" """ config = get_config() - + try: - result = run_command("manage_editor", {"action": "add_layer", "layerName": layer_name}, config) + result = run_command( + "manage_editor", {"action": "add_layer", "layerName": layer_name}, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success(f"Added layer: {layer_name}") @@ -200,15 +203,16 @@ def add_layer(layer_name: str): @click.argument("layer_name") def remove_layer(layer_name: str): """Remove a layer. - + \b Examples: unity-mcp editor remove-layer "OldLayer" """ config = get_config() - + try: - result = run_command("manage_editor", {"action": "remove_layer", "layerName": layer_name}, config) + result = run_command( + "manage_editor", {"action": "remove_layer", "layerName": layer_name}, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success(f"Removed layer: {layer_name}") @@ -221,7 +225,7 @@ def remove_layer(layer_name: str): @click.argument("tool_name") def set_tool(tool_name: str): """Set the active editor tool. - + \b Examples: unity-mcp editor tool "Move" @@ -229,9 +233,10 @@ def set_tool(tool_name: str): unity-mcp editor tool "Scale" """ config = get_config() - + try: - result = run_command("manage_editor", {"action": "set_active_tool", "toolName": tool_name}, config) + result = run_command( + "manage_editor", {"action": "set_active_tool", "toolName": tool_name}, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success(f"Set active tool: {tool_name}") @@ -244,7 +249,7 @@ def set_tool(tool_name: str): @click.argument("menu_path") def execute_menu(menu_path: str): """Execute a menu item. - + \b Examples: unity-mcp editor menu "File/Save" @@ -252,9 +257,10 @@ def execute_menu(menu_path: str): unity-mcp editor menu "GameObject/Create Empty" """ config = get_config() - + try: - result = run_command("execute_menu_item", {"menu_path": menu_path}, config) + result = run_command("execute_menu_item", { + "menu_path": menu_path}, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success(f"Executed: {menu_path}") @@ -293,7 +299,7 @@ def execute_menu(menu_path: str): ) def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, failed_only: bool): """Run Unity tests. - + \b Examples: unity-mcp editor tests @@ -302,16 +308,16 @@ def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, f unity-mcp editor tests --wait 60 --failed-only """ config = get_config() - + params: dict[str, Any] = {"mode": mode} if details: params["include_details"] = True if failed_only: params["include_failed_tests"] = True - + try: result = run_command("run_tests", params, config) - + # For async mode, just show job ID if async_mode and result.get("success"): job_id = result.get("data", {}).get("job_id") @@ -319,7 +325,7 @@ def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, f click.echo(f"Test job started: {job_id}") print_info("Poll with: unity-mcp editor poll-test " + job_id) return - + click.echo(format_output(result, config.format)) except UnityConnectionError as e: print_error(str(e)) @@ -346,7 +352,7 @@ def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, f ) def poll_test(job_id: str, wait: int, details: bool, failed_only: bool): """Poll an async test job for status/results. - + \b Examples: unity-mcp editor poll-test abc123 @@ -354,7 +360,7 @@ def poll_test(job_id: str, wait: int, details: bool, failed_only: bool): unity-mcp editor poll-test abc123 --failed-only """ config = get_config() - + params: dict[str, Any] = {"job_id": job_id} if wait: params["wait_timeout"] = wait @@ -362,11 +368,11 @@ def poll_test(job_id: str, wait: int, details: bool, failed_only: bool): params["include_details"] = True if failed_only: params["include_failed_tests"] = True - + try: result = run_command("get_test_job", params, config) click.echo(format_output(result, config.format)) - + if isinstance(result, dict) and result.get("success"): data = result.get("data", {}) status = data.get("status", "unknown") @@ -411,7 +417,7 @@ def poll_test(job_id: str, wait: int, details: bool, failed_only: bool): ) def refresh(mode: str, scope: str, compile: bool, no_wait: bool): """Force Unity to refresh assets/scripts. - + \b Examples: unity-mcp editor refresh @@ -420,7 +426,7 @@ def refresh(mode: str, scope: str, compile: bool, no_wait: bool): unity-mcp editor refresh --scope scripts --compile """ config = get_config() - + params: dict[str, Any] = { "mode": mode, "scope": scope, @@ -428,7 +434,7 @@ def refresh(mode: str, scope: str, compile: bool, no_wait: bool): } if compile: params["compile"] = "request" - + try: click.echo("Refreshing Unity...") result = run_command("refresh_unity", params, config) @@ -449,9 +455,9 @@ def refresh(mode: str, scope: str, compile: bool, no_wait: bool): ) def custom_tool(tool_name: str, params: str): """Execute a custom Unity tool. - + Custom tools are registered by Unity projects via the MCP plugin. - + \b Examples: unity-mcp editor custom-tool "MyCustomTool" @@ -459,13 +465,13 @@ def custom_tool(tool_name: str, params: str): """ import json config = get_config() - + try: params_dict = json.loads(params) except json.JSONDecodeError as e: print_error(f"Invalid JSON for params: {e}") sys.exit(1) - + try: result = run_command("execute_custom_tool", { "tool_name": tool_name, diff --git a/Server/src/cli/commands/gameobject.py b/Server/src/cli/commands/gameobject.py index 083ba4479..f4994a6f4 100644 --- a/Server/src/cli/commands/gameobject.py +++ b/Server/src/cli/commands/gameobject.py @@ -20,7 +20,8 @@ def gameobject(): @click.argument("search_term") @click.option( "--method", "-m", - type=click.Choice(["by_name", "by_tag", "by_layer", "by_component", "by_path", "by_id"]), + type=click.Choice(["by_name", "by_tag", "by_layer", + "by_component", "by_path", "by_id"]), default="by_name", help="Search method." ) @@ -43,7 +44,7 @@ def gameobject(): ) def find(search_term: str, method: str, include_inactive: bool, limit: int, cursor: int): """Find GameObjects by search criteria. - + \b Examples: unity-mcp gameobject find "Player" @@ -53,7 +54,7 @@ def find(search_term: str, method: str, include_inactive: bool, limit: int, curs unity-mcp gameobject find "/Canvas/Panel" --method by_path """ config = get_config() - + try: result = run_command("find_gameobjects", { "searchMethod": method, @@ -72,7 +73,8 @@ def find(search_term: str, method: str, include_inactive: bool, limit: int, curs @click.argument("name") @click.option( "--primitive", "-p", - type=click.Choice(["Cube", "Sphere", "Cylinder", "Plane", "Capsule", "Quad"]), + type=click.Choice(["Cube", "Sphere", "Cylinder", + "Plane", "Capsule", "Quad"]), help="Create a primitive type." ) @click.option( @@ -140,7 +142,7 @@ def create( prefab_path: Optional[str], ): """Create a new GameObject. - + \b Examples: unity-mcp gameobject create "MyCube" --primitive Cube @@ -150,12 +152,12 @@ def create( unity-mcp gameobject create "Item" --components "Rigidbody,BoxCollider" """ config = get_config() - + params: dict[str, Any] = { "action": "create", "name": name, } - + if primitive: params["primitiveType"] = primitive if position: @@ -174,10 +176,10 @@ def create( params["saveAsPrefab"] = True if prefab_path: params["prefabPath"] = prefab_path - + try: result = run_command("manage_gameobject", params, config) - + # Add components separately since componentsToAdd doesn't work if components and (result.get("success") or result.get("data") or result.get("result")): component_list = [c.strip() for c in components.split(",")] @@ -192,8 +194,9 @@ def create( except UnityConnectionError: failed_components.append(component) if failed_components: - print_warning(f"Failed to add components: {', '.join(failed_components)}") - + print_warning( + f"Failed to add components: {', '.join(failed_components)}") + click.echo(format_output(result, config.format)) if result.get("success") or result.get("result"): print_success(f"Created GameObject '{name}'") @@ -281,9 +284,9 @@ def modify( search_method: Optional[str], ): """Modify an existing GameObject. - + TARGET can be a name, path, instance ID, or tag depending on --search-method. - + \b Examples: unity-mcp gameobject modify "Player" --position 0 5 0 @@ -293,12 +296,12 @@ def modify( unity-mcp gameobject modify "Cube" --add-components "Rigidbody,BoxCollider" """ config = get_config() - + params: dict[str, Any] = { "action": "modify", "target": target, } - + if name: params["name"] = name if position: @@ -316,12 +319,14 @@ def modify( if active is not None: params["setActive"] = active if add_components: - params["componentsToAdd"] = [c.strip() for c in add_components.split(",")] + params["componentsToAdd"] = [c.strip() + for c in add_components.split(",")] if remove_components: - params["componentsToRemove"] = [c.strip() for c in remove_components.split(",")] + params["componentsToRemove"] = [c.strip() + for c in remove_components.split(",")] if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_gameobject", params, config) click.echo(format_output(result, config.format)) @@ -345,7 +350,7 @@ def modify( ) def delete(target: str, search_method: Optional[str], force: bool): """Delete a GameObject. - + \b Examples: unity-mcp gameobject delete "OldObject" @@ -353,18 +358,18 @@ def delete(target: str, search_method: Optional[str], force: bool): unity-mcp gameobject delete "TempObjects" --search-method by_tag --force """ config = get_config() - + if not force: click.confirm(f"Delete GameObject '{target}'?", abort=True) - + params = { "action": "delete", "target": target, } - + if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_gameobject", params, config) click.echo(format_output(result, config.format)) @@ -402,7 +407,7 @@ def duplicate( search_method: Optional[str], ): """Duplicate a GameObject. - + \b Examples: unity-mcp gameobject duplicate "Player" @@ -410,19 +415,19 @@ def duplicate( unity-mcp gameobject duplicate "-81840" --search-method by_id """ config = get_config() - + params: dict[str, Any] = { "action": "duplicate", "target": target, } - + if name: params["new_name"] = name if offset: params["offset"] = list(offset) if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_gameobject", params, config) click.echo(format_output(result, config.format)) @@ -442,7 +447,8 @@ def duplicate( ) @click.option( "--direction", "-d", - type=click.Choice(["left", "right", "up", "down", "forward", "back", "front", "backward", "behind"]), + type=click.Choice(["left", "right", "up", "down", "forward", + "back", "front", "backward", "behind"]), required=True, help="Direction to move." ) @@ -472,7 +478,7 @@ def move( search_method: Optional[str], ): """Move a GameObject relative to another object. - + \b Examples: unity-mcp gameobject move "Chair" --reference "Table" --direction right --distance 2 @@ -480,7 +486,7 @@ def move( unity-mcp gameobject move "NPC" --reference "Player" --direction forward --local """ config = get_config() - + params: dict[str, Any] = { "action": "move_relative", "target": target, @@ -489,15 +495,16 @@ def move( "distance": distance, "world_space": not local, } - + if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_gameobject", params, config) click.echo(format_output(result, config.format)) if result.get("success"): - print_success(f"Moved '{target}' {direction} of '{reference}' by {distance} units") + print_success( + f"Moved '{target}' {direction} of '{reference}' by {distance} units") except UnityConnectionError as e: print_error(str(e)) sys.exit(1) diff --git a/Server/src/cli/commands/instance.py b/Server/src/cli/commands/instance.py index 8312342cc..9ce8d7fcb 100644 --- a/Server/src/cli/commands/instance.py +++ b/Server/src/cli/commands/instance.py @@ -18,34 +18,35 @@ def instance(): @instance.command("list") def list_instances(): """List available Unity instances. - + \\b Examples: unity-mcp instance list """ config = get_config() - + try: result = run_list_instances(config) - instances = result.get("instances", []) if isinstance(result, dict) else [] - + instances = result.get("instances", []) if isinstance( + result, dict) else [] + if not instances: print_info("No Unity instances currently connected") return - + click.echo("Available Unity instances:") for inst in instances: project = inst.get("project", "Unknown") version = inst.get("unity_version", "Unknown") hash_id = inst.get("hash", "") session_id = inst.get("session_id", "") - + # Format: ProjectName@hash (Unity version) display_id = f"{project}@{hash_id}" if hash_id else project click.echo(f" • {display_id} (Unity {version})") if session_id: click.echo(f" Session: {session_id[:8]}...") - + except UnityConnectionError as e: print_error(str(e)) sys.exit(1) @@ -55,16 +56,16 @@ def list_instances(): @click.argument("instance_id") def set_instance(instance_id: str): """Set the active Unity instance. - + INSTANCE_ID can be Name@hash or just a hash prefix. - + \\b Examples: unity-mcp instance set "MyProject@abc123" unity-mcp instance set abc123 """ config = get_config() - + try: result = run_command("set_active_instance", { "instance": instance_id, @@ -82,18 +83,19 @@ def set_instance(instance_id: str): @instance.command("current") def current_instance(): """Show the currently selected Unity instance. - + \\b Examples: unity-mcp instance current """ config = get_config() - + # The current instance is typically shown in telemetry or needs to be tracked # For now, we can show the configured instance from CLI options if config.unity_instance: click.echo(f"Configured instance: {config.unity_instance}") else: - print_info("No instance explicitly set. Using default (auto-select single instance).") + print_info( + "No instance explicitly set. Using default (auto-select single instance).") print_info("Use 'unity-mcp instance list' to see available instances.") print_info("Use 'unity-mcp instance set ' to select one.") diff --git a/Server/src/cli/commands/lighting.py b/Server/src/cli/commands/lighting.py index 4010bdd03..ef0cb878a 100644 --- a/Server/src/cli/commands/lighting.py +++ b/Server/src/cli/commands/lighting.py @@ -46,7 +46,7 @@ def lighting(): ) def create(name: str, light_type: str, position: Tuple[float, float, float], color: Optional[Tuple[float, float, float]], intensity: Optional[float]): """Create a new light. - + \b Examples: unity-mcp lighting create "MainLight" --type Directional @@ -54,7 +54,7 @@ def create(name: str, light_type: str, position: Tuple[float, float, float], col unity-mcp lighting create "RedLight" --type Spot --color 1 0 0 """ config = get_config() - + try: # Step 1: Create empty GameObject with position create_result = run_command("manage_gameobject", { @@ -62,22 +62,22 @@ def create(name: str, light_type: str, position: Tuple[float, float, float], col "name": name, "position": list(position), }, config) - + if not (create_result.get("success")): click.echo(format_output(create_result, config.format)) return - + # Step 2: Add Light component using manage_components add_result = run_command("manage_components", { "action": "add", "target": name, "componentType": "Light", }, config) - + if not add_result.get("success"): click.echo(format_output(add_result, config.format)) return - + # Step 3: Set light type using manage_components set_property type_result = run_command("manage_components", { "action": "set_property", @@ -86,43 +86,43 @@ def create(name: str, light_type: str, position: Tuple[float, float, float], col "property": "type", "value": light_type, }, config) - + if not type_result.get("success"): click.echo(format_output(type_result, config.format)) return - + # Step 4: Set color if provided if color: color_result = run_command("manage_components", { - "action": "set_property", - "target": name, - "componentType": "Light", - "property": "color", - "value": {"r": color[0], "g": color[1], "b": color[2], "a": 1}, + "action": "set_property", + "target": name, + "componentType": "Light", + "property": "color", + "value": {"r": color[0], "g": color[1], "b": color[2], "a": 1}, }, config) - + if not color_result.get("success"): click.echo(format_output(color_result, config.format)) return - + # Step 5: Set intensity if provided if intensity is not None: intensity_result = run_command("manage_components", { - "action": "set_property", - "target": name, - "componentType": "Light", - "property": "intensity", - "value": intensity, + "action": "set_property", + "target": name, + "componentType": "Light", + "property": "intensity", + "value": intensity, }, config) - + if not intensity_result.get("success"): click.echo(format_output(intensity_result, config.format)) return - + # Output the result click.echo(format_output(create_result, config.format)) print_success(f"Created {light_type} light: {name}") - + except UnityConnectionError as e: print_error(str(e)) sys.exit(1) diff --git a/Server/src/cli/commands/material.py b/Server/src/cli/commands/material.py index 4488c2fd1..9949281a4 100644 --- a/Server/src/cli/commands/material.py +++ b/Server/src/cli/commands/material.py @@ -20,13 +20,13 @@ def material(): @click.argument("path") def info(path: str): """Get information about a material. - + \b Examples: unity-mcp material info "Assets/Materials/Red.mat" """ config = get_config() - + try: result = run_command("manage_material", { "action": "get_material_info", @@ -52,7 +52,7 @@ def info(path: str): ) def create(path: str, shader: str, properties: Optional[str]): """Create a new material. - + \b Examples: unity-mcp material create "Assets/Materials/NewMat.mat" @@ -60,20 +60,20 @@ def create(path: str, shader: str, properties: Optional[str]): unity-mcp material create "Assets/Materials/Blue.mat" --properties '{"_Color": [0,0,1,1]}' """ config = get_config() - + params: dict[str, Any] = { "action": "create", "materialPath": path, "shader": shader, } - + if properties: try: params["properties"] = json.loads(properties) except json.JSONDecodeError as e: print_error(f"Invalid JSON for properties: {e}") sys.exit(1) - + try: result = run_command("manage_material", params, config) click.echo(format_output(result, config.format)) @@ -97,7 +97,7 @@ def create(path: str, shader: str, properties: Optional[str]): ) def set_color(path: str, r: float, g: float, b: float, a: float, property: str): """Set a material's color. - + \b Examples: unity-mcp material set-color "Assets/Materials/Red.mat" 1 0 0 @@ -105,14 +105,14 @@ def set_color(path: str, r: float, g: float, b: float, a: float, property: str): unity-mcp material set-color "Assets/Materials/Mat.mat" 1 1 0 --property "_BaseColor" """ config = get_config() - + params: dict[str, Any] = { "action": "set_material_color", "materialPath": path, "property": property, "color": [r, g, b, a], } - + try: result = run_command("manage_material", params, config) click.echo(format_output(result, config.format)) @@ -129,7 +129,7 @@ def set_color(path: str, r: float, g: float, b: float, a: float, property: str): @click.argument("value") def set_property(path: str, property_name: str, value: str): """Set a shader property on a material. - + \b Examples: unity-mcp material set-property "Assets/Materials/Mat.mat" _Metallic 0.5 @@ -137,7 +137,7 @@ def set_property(path: str, property_name: str, value: str): unity-mcp material set-property "Assets/Materials/Mat.mat" _MainTex "Assets/Textures/Tex.png" """ config = get_config() - + # Try to parse value as JSON for complex types try: parsed_value = json.loads(value) @@ -147,14 +147,14 @@ def set_property(path: str, property_name: str, value: str): parsed_value = float(value) except ValueError: parsed_value = value - + params: dict[str, Any] = { "action": "set_material_shader_property", "materialPath": path, "property": property_name, "value": parsed_value, } - + try: result = run_command("manage_material", params, config) click.echo(format_output(result, config.format)) @@ -170,7 +170,8 @@ def set_property(path: str, property_name: str, value: str): @click.argument("target") @click.option( "--search-method", - type=click.Choice(["by_name", "by_path", "by_tag", "by_layer", "by_component"]), + type=click.Choice(["by_name", "by_path", "by_tag", + "by_layer", "by_component"]), default=None, help="How to find the target GameObject." ) @@ -188,7 +189,7 @@ def set_property(path: str, property_name: str, value: str): ) def assign(material_path: str, target: str, search_method: Optional[str], slot: int, mode: str): """Assign a material to a GameObject's renderer. - + \b Examples: unity-mcp material assign "Assets/Materials/Red.mat" "Cube" @@ -196,7 +197,7 @@ def assign(material_path: str, target: str, search_method: Optional[str], slot: unity-mcp material assign "Assets/Materials/Mat.mat" "-81840" --search-method by_id --slot 1 """ config = get_config() - + params: dict[str, Any] = { "action": "assign_material_to_renderer", "materialPath": material_path, @@ -204,10 +205,10 @@ def assign(material_path: str, target: str, search_method: Optional[str], slot: "slot": slot, "mode": mode, } - + if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_material", params, config) click.echo(format_output(result, config.format)) @@ -226,7 +227,8 @@ def assign(material_path: str, target: str, search_method: Optional[str], slot: @click.argument("a", type=float, default=1.0) @click.option( "--search-method", - type=click.Choice(["by_name", "by_path", "by_tag", "by_layer", "by_component"]), + type=click.Choice(["by_name", "by_path", "by_tag", + "by_layer", "by_component"]), default=None, help="How to find the target GameObject." ) @@ -238,24 +240,24 @@ def assign(material_path: str, target: str, search_method: Optional[str], slot: ) def set_renderer_color(target: str, r: float, g: float, b: float, a: float, search_method: Optional[str], mode: str): """Set a renderer's material color directly. - + \b Examples: unity-mcp material set-renderer-color "Cube" 1 0 0 unity-mcp material set-renderer-color "Player" 0 1 0 --mode instance """ config = get_config() - + params: dict[str, Any] = { "action": "set_renderer_color", "target": target, "color": [r, g, b, a], "mode": mode, } - + if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_material", params, config) click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/commands/prefab.py b/Server/src/cli/commands/prefab.py index 3191c11e9..3a005fda6 100644 --- a/Server/src/cli/commands/prefab.py +++ b/Server/src/cli/commands/prefab.py @@ -24,19 +24,19 @@ def prefab(): ) def open_stage(path: str, mode: str): """Open a prefab in the prefab stage for editing. - + \b Examples: unity-mcp prefab open "Assets/Prefabs/Player.prefab" """ config = get_config() - + params: dict[str, Any] = { "action": "open_stage", "prefabPath": path, "mode": mode, } - + try: result = run_command("manage_prefabs", params, config) click.echo(format_output(result, config.format)) @@ -55,20 +55,20 @@ def open_stage(path: str, mode: str): ) def close_stage(save: bool): """Close the current prefab stage. - + \b Examples: unity-mcp prefab close unity-mcp prefab close --save """ config = get_config() - + params: dict[str, Any] = { "action": "close_stage", } if save: params["saveBeforeClose"] = True - + try: result = run_command("manage_prefabs", params, config) click.echo(format_output(result, config.format)) @@ -82,15 +82,16 @@ def close_stage(save: bool): @prefab.command("save") def save_stage(): """Save the currently open prefab stage. - + \b Examples: unity-mcp prefab save """ config = get_config() - + try: - result = run_command("manage_prefabs", {"action": "save_open_stage"}, config) + result = run_command("manage_prefabs", { + "action": "save_open_stage"}, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success("Saved prefab") @@ -114,25 +115,25 @@ def save_stage(): ) def create(target: str, path: str, overwrite: bool, include_inactive: bool): """Create a prefab from a scene GameObject. - + \b Examples: unity-mcp prefab create "Player" "Assets/Prefabs/Player.prefab" unity-mcp prefab create "Enemy" "Assets/Prefabs/Enemy.prefab" --overwrite """ config = get_config() - + params: dict[str, Any] = { "action": "create_from_gameobject", "target": target, "prefabPath": path, } - + if overwrite: params["allowOverwrite"] = True if include_inactive: params["searchInactive"] = True - + try: result = run_command("manage_prefabs", params, config) click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/commands/scene.py b/Server/src/cli/commands/scene.py index a04b20446..896bed5bc 100644 --- a/Server/src/cli/commands/scene.py +++ b/Server/src/cli/commands/scene.py @@ -52,7 +52,7 @@ def hierarchy( cursor: int, ): """Get the scene hierarchy. - + \b Examples: unity-mcp scene hierarchy @@ -61,20 +61,20 @@ def hierarchy( unity-mcp scene hierarchy --format json """ config = get_config() - + params: dict[str, Any] = { "action": "get_hierarchy", "pageSize": limit, "cursor": cursor, } - + if parent: params["parent"] = parent if max_depth is not None: params["maxDepth"] = max_depth if include_transform: params["includeTransform"] = True - + try: result = run_command("manage_scene", params, config) click.echo(format_output(result, config.format)) @@ -87,7 +87,7 @@ def hierarchy( def active(): """Get information about the active scene.""" config = get_config() - + try: result = run_command("manage_scene", {"action": "get_active"}, config) click.echo(format_output(result, config.format)) @@ -105,7 +105,7 @@ def active(): ) def load(scene: str, by_index: bool): """Load a scene. - + \b Examples: unity-mcp scene load "Assets/Scenes/Main.unity" @@ -113,9 +113,9 @@ def load(scene: str, by_index: bool): unity-mcp scene load 0 --by-index """ config = get_config() - + params: dict[str, Any] = {"action": "load"} - + if by_index: try: params["buildIndex"] = int(scene) @@ -127,7 +127,7 @@ def load(scene: str, by_index: bool): params["path"] = scene else: params["name"] = scene - + try: result = run_command("manage_scene", params, config) click.echo(format_output(result, config.format)) @@ -146,18 +146,18 @@ def load(scene: str, by_index: bool): ) def save(path: Optional[str]): """Save the current scene. - + \b Examples: unity-mcp scene save unity-mcp scene save --path "Assets/Scenes/NewScene.unity" """ config = get_config() - + params: dict[str, Any] = {"action": "save"} if path: params["path"] = path - + try: result = run_command("manage_scene", params, config) click.echo(format_output(result, config.format)) @@ -177,21 +177,21 @@ def save(path: Optional[str]): ) def create(name: str, path: Optional[str]): """Create a new scene. - + \b Examples: unity-mcp scene create "NewLevel" unity-mcp scene create "TestScene" --path "Assets/Scenes/Test" """ config = get_config() - + params: dict[str, Any] = { "action": "create", "name": name, } if path: params["path"] = path - + try: result = run_command("manage_scene", params, config) click.echo(format_output(result, config.format)) @@ -206,9 +206,10 @@ def create(name: str, path: Optional[str]): def build_settings(): """Get scenes in build settings.""" config = get_config() - + try: - result = run_command("manage_scene", {"action": "get_build_settings"}, config) + result = run_command( + "manage_scene", {"action": "get_build_settings"}, config) click.echo(format_output(result, config.format)) except UnityConnectionError as e: print_error(str(e)) @@ -229,7 +230,7 @@ def build_settings(): ) def screenshot(filename: Optional[str], supersize: int): """Capture a screenshot of the scene. - + \b Examples: unity-mcp scene screenshot @@ -237,13 +238,13 @@ def screenshot(filename: Optional[str], supersize: int): unity-mcp scene screenshot --supersize 2 """ config = get_config() - + params: dict[str, Any] = {"action": "screenshot"} if filename: params["fileName"] = filename if supersize > 1: params["superSize"] = supersize - + try: result = run_command("manage_scene", params, config) click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/commands/script.py b/Server/src/cli/commands/script.py index d58d87d11..a7f376cc1 100644 --- a/Server/src/cli/commands/script.py +++ b/Server/src/cli/commands/script.py @@ -26,7 +26,8 @@ def script(): @click.option( "--type", "-t", "script_type", - type=click.Choice(["MonoBehaviour", "ScriptableObject", "Editor", "EditorWindow", "Plain"]), + type=click.Choice(["MonoBehaviour", "ScriptableObject", + "Editor", "EditorWindow", "Plain"]), default="MonoBehaviour", help="Type of script to create." ) @@ -42,7 +43,7 @@ def script(): ) def create(name: str, path: str, script_type: str, namespace: Optional[str], contents: Optional[str]): """Create a new C# script. - + \b Examples: unity-mcp script create "PlayerController" @@ -51,19 +52,19 @@ def create(name: str, path: str, script_type: str, namespace: Optional[str], con unity-mcp script create "CustomEditor" --type Editor --namespace "MyGame.Editor" """ config = get_config() - + params: dict[str, Any] = { "action": "create", "name": name, "path": path, "scriptType": script_type, } - + if namespace: params["namespace"] = namespace if contents: params["contents"] = contents - + try: result = run_command("manage_script", params, config) click.echo(format_output(result, config.format)) @@ -90,14 +91,14 @@ def create(name: str, path: str, script_type: str, namespace: Optional[str], con ) def read(path: str, start_line: Optional[int], line_count: Optional[int]): """Read a C# script file. - + \b Examples: unity-mcp script read "Assets/Scripts/Player.cs" unity-mcp script read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20 """ config = get_config() - + parts = path.rsplit("/", 1) filename = parts[-1] directory = parts[0] if len(parts) > 1 else "Assets" @@ -108,12 +109,12 @@ def read(path: str, start_line: Optional[int], line_count: Optional[int]): "name": name, "path": directory, } - + if start_line: params["startLine"] = start_line if line_count: params["lineCount"] = line_count - + try: result = run_command("manage_script", params, config) # For read, just output the content directly @@ -139,16 +140,16 @@ def read(path: str, start_line: Optional[int], line_count: Optional[int]): ) def delete(path: str, force: bool): """Delete a C# script. - + \b Examples: unity-mcp script delete "Assets/Scripts/OldScript.cs" """ config = get_config() - + if not force: click.confirm(f"Delete script '{path}'?", abort=True) - + parts = path.rsplit("/", 1) filename = parts[-1] directory = parts[0] if len(parts) > 1 else "Assets" @@ -159,7 +160,7 @@ def delete(path: str, force: bool): "name": name, "path": directory, } - + try: result = run_command("manage_script", params, config) click.echo(format_output(result, config.format)) @@ -179,24 +180,24 @@ def delete(path: str, force: bool): ) def edit(path: str, edits: str): """Apply text edits to a script. - + \b Examples: unity-mcp script edit "Assets/Scripts/Player.cs" --edits '[{"startLine": 10, "startCol": 1, "endLine": 10, "endCol": 20, "newText": "// Modified"}]' """ config = get_config() - + try: edits_list = json.loads(edits) except json.JSONDecodeError as e: print_error(f"Invalid JSON for edits: {e}") sys.exit(1) - + params: dict[str, Any] = { "uri": path, "edits": edits_list, } - + try: result = run_command("apply_text_edits", params, config) click.echo(format_output(result, config.format)) @@ -217,20 +218,20 @@ def edit(path: str, edits: str): ) def validate(path: str, level: str): """Validate a C# script for errors. - + \b Examples: unity-mcp script validate "Assets/Scripts/Player.cs" unity-mcp script validate "Assets/Scripts/Player.cs" --level standard """ config = get_config() - + params: dict[str, Any] = { "uri": path, "level": level, "include_diagnostics": True, } - + try: result = run_command("validate_script", params, config) click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/commands/shader.py b/Server/src/cli/commands/shader.py index 0199a5d23..4b8938a48 100644 --- a/Server/src/cli/commands/shader.py +++ b/Server/src/cli/commands/shader.py @@ -19,25 +19,25 @@ def shader(): @click.argument("path") def read_shader(path: str): """Read a shader file. - + \\b Examples: unity-mcp shader read "Assets/Shaders/MyShader.shader" """ config = get_config() - + # Extract name from path import os name = os.path.splitext(os.path.basename(path))[0] directory = os.path.dirname(path) - + try: result = run_command("manage_shader", { "action": "read", "name": name, "path": directory or "Assets/", }, config) - + # If successful, display the contents nicely if result.get("success") and result.get("data", {}).get("contents"): click.echo(result["data"]["contents"]) @@ -69,7 +69,7 @@ def read_shader(path: str): ) def create_shader(name: str, path: str, contents: Optional[str], file_path: Optional[str]): """Create a new shader. - + \\b Examples: unity-mcp shader create "MyShader" --path "Assets/Shaders" @@ -77,7 +77,7 @@ def create_shader(name: str, path: str, contents: Optional[str], file_path: Opti echo "Shader code..." | unity-mcp shader create "MyShader" """ config = get_config() - + # Get contents from file, option, or stdin if file_path: with open(file_path, 'r') as f: @@ -126,7 +126,7 @@ def create_shader(name: str, path: str, contents: Optional[str], file_path: Opti FallBack "Diffuse" }} ''' - + try: result = run_command("manage_shader", { "action": "create", @@ -158,18 +158,18 @@ def create_shader(name: str, path: str, contents: Optional[str], file_path: Opti ) def update_shader(path: str, contents: Optional[str], file_path: Optional[str]): """Update an existing shader. - + \\b Examples: unity-mcp shader update "Assets/Shaders/MyShader.shader" --file updated.shader echo "New shader code" | unity-mcp shader update "Assets/Shaders/MyShader.shader" """ config = get_config() - + import os name = os.path.splitext(os.path.basename(path))[0] directory = os.path.dirname(path) - + # Get contents from file, option, or stdin if file_path: with open(file_path, 'r') as f: @@ -181,9 +181,10 @@ def update_shader(path: str, contents: Optional[str], file_path: Optional[str]): if not sys.stdin.isatty(): shader_contents = sys.stdin.read() else: - print_error("No shader contents provided. Use --contents, --file, or pipe via stdin.") + print_error( + "No shader contents provided. Use --contents, --file, or pipe via stdin.") sys.exit(1) - + try: result = run_command("manage_shader", { "action": "update", @@ -208,21 +209,21 @@ def update_shader(path: str, contents: Optional[str], file_path: Optional[str]): ) def delete_shader(path: str, force: bool): """Delete a shader. - + \\b Examples: unity-mcp shader delete "Assets/Shaders/OldShader.shader" unity-mcp shader delete "Assets/Shaders/OldShader.shader" --force """ config = get_config() - + if not force: click.confirm(f"Delete shader '{path}'?", abort=True) - + import os name = os.path.splitext(os.path.basename(path))[0] directory = os.path.dirname(path) - + try: result = run_command("manage_shader", { "action": "delete", diff --git a/Server/src/cli/commands/ui.py b/Server/src/cli/commands/ui.py index c61bf1763..a8a0d5ce9 100644 --- a/Server/src/cli/commands/ui.py +++ b/Server/src/cli/commands/ui.py @@ -19,31 +19,32 @@ def ui(): @click.argument("name") @click.option( "--render-mode", - type=click.Choice(["ScreenSpaceOverlay", "ScreenSpaceCamera", "WorldSpace"]), + type=click.Choice( + ["ScreenSpaceOverlay", "ScreenSpaceCamera", "WorldSpace"]), default="ScreenSpaceOverlay", help="Canvas render mode." ) def create_canvas(name: str, render_mode: str): """Create a new Canvas. - + \b Examples: unity-mcp ui create-canvas "MainUI" unity-mcp ui create-canvas "WorldUI" --render-mode WorldSpace """ config = get_config() - + try: # Step 1: Create empty GameObject result = run_command("manage_gameobject", { "action": "create", "name": name, }, config) - + if not (result.get("success") or result.get("data") or result.get("result")): click.echo(format_output(result, config.format)) return - + # Step 2: Add Canvas components for component in ["Canvas", "CanvasScaler", "GraphicRaycaster"]: run_command("manage_components", { @@ -51,9 +52,10 @@ def create_canvas(name: str, render_mode: str): "target": name, "componentType": component, }, config) - + # Step 3: Set render mode - render_mode_value = {"ScreenSpaceOverlay": 0, "ScreenSpaceCamera": 1, "WorldSpace": 2}.get(render_mode, 0) + render_mode_value = {"ScreenSpaceOverlay": 0, + "ScreenSpaceCamera": 1, "WorldSpace": 2}.get(render_mode, 0) run_command("manage_components", { "action": "set_property", "target": name, @@ -61,7 +63,7 @@ def create_canvas(name: str, render_mode: str): "property": "renderMode", "value": render_mode_value, }, config) - + click.echo(format_output(result, config.format)) print_success(f"Created Canvas: {name}") except UnityConnectionError as e: @@ -90,13 +92,13 @@ def create_canvas(name: str, render_mode: str): ) def create_text(name: str, parent: str, text: str, position: tuple): """Create a UI Text element (TextMeshPro). - + \b Examples: unity-mcp ui create-text "TitleText" --parent "MainUI" --text "Hello World" """ config = get_config() - + try: # Step 1: Create empty GameObject with parent result = run_command("manage_gameobject", { @@ -105,18 +107,18 @@ def create_text(name: str, parent: str, text: str, position: tuple): "parent": parent, "position": list(position), }, config) - + if not (result.get("success") or result.get("data") or result.get("result")): click.echo(format_output(result, config.format)) return - + # Step 2: Add RectTransform and TextMeshProUGUI run_command("manage_components", { "action": "add", "target": name, "componentType": "TextMeshProUGUI", }, config) - + # Step 3: Set text content run_command("manage_components", { "action": "set_property", @@ -125,7 +127,7 @@ def create_text(name: str, parent: str, text: str, position: tuple): "property": "text", "value": text, }, config) - + click.echo(format_output(result, config.format)) print_success(f"Created Text: {name}") except UnityConnectionError as e: @@ -145,15 +147,15 @@ def create_text(name: str, parent: str, text: str, position: tuple): default="Button", help="Button label text." ) -def create_button(name: str, parent: str, text: str): #text current placeholder +def create_button(name: str, parent: str, text: str): # text current placeholder """Create a UI Button. - + \b Examples: unity-mcp ui create-button "StartButton" --parent "MainUI" --text "Start Game" """ config = get_config() - + try: # Step 1: Create empty GameObject with parent result = run_command("manage_gameobject", { @@ -217,14 +219,14 @@ def create_button(name: str, parent: str, text: str): #text current placeholder ) def create_image(name: str, parent: str, sprite: Optional[str]): """Create a UI Image. - + \b Examples: unity-mcp ui create-image "Background" --parent "MainUI" unity-mcp ui create-image "Icon" --parent "MainUI" --sprite "Assets/Sprites/icon.png" """ config = get_config() - + try: # Step 1: Create empty GameObject with parent result = run_command("manage_gameobject", { @@ -232,11 +234,11 @@ def create_image(name: str, parent: str, sprite: Optional[str]): "name": name, "parent": parent, }, config) - + if not (result.get("success") or result.get("data") or result.get("result")): click.echo(format_output(result, config.format)) return - + # Step 2: Add Image component run_command("manage_components", { "action": "add", diff --git a/Server/src/cli/commands/vfx.py b/Server/src/cli/commands/vfx.py index 224ab7f28..c57a5decc 100644 --- a/Server/src/cli/commands/vfx.py +++ b/Server/src/cli/commands/vfx.py @@ -31,7 +31,7 @@ def particle(): @click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) def particle_info(target: str, search_method: Optional[str]): """Get particle system info. - + \\b Examples: unity-mcp vfx particle info "Fire" @@ -41,7 +41,7 @@ def particle_info(target: str, search_method: Optional[str]): params: dict[str, Any] = {"action": "particle_get_info", "target": target} if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -56,7 +56,7 @@ def particle_info(target: str, search_method: Optional[str]): @click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) def particle_play(target: str, with_children: bool, search_method: Optional[str]): """Play a particle system. - + \\b Examples: unity-mcp vfx particle play "Fire" @@ -68,7 +68,7 @@ def particle_play(target: str, with_children: bool, search_method: Optional[str] params["withChildren"] = True if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -91,7 +91,7 @@ def particle_stop(target: str, with_children: bool, search_method: Optional[str] params["withChildren"] = True if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -111,7 +111,7 @@ def particle_pause(target: str, search_method: Optional[str]): params: dict[str, Any] = {"action": "particle_pause", "target": target} if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -132,7 +132,7 @@ def particle_restart(target: str, with_children: bool, search_method: Optional[s params["withChildren"] = True if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -153,7 +153,7 @@ def particle_clear(target: str, with_children: bool, search_method: Optional[str params["withChildren"] = True if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -177,7 +177,7 @@ def line(): @click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) def line_info(target: str, search_method: Optional[str]): """Get line renderer info. - + \\b Examples: unity-mcp vfx line info "LaserBeam" @@ -186,7 +186,7 @@ def line_info(target: str, search_method: Optional[str]): params: dict[str, Any] = {"action": "line_get_info", "target": target} if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -201,19 +201,19 @@ def line_info(target: str, search_method: Optional[str]): @click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) def line_set_positions(target: str, positions: str, search_method: Optional[str]): """Set all positions on a line renderer. - + \\b Examples: unity-mcp vfx line set-positions "Line" --positions "[[0,0,0], [5,2,0], [10,0,0]]" """ config = get_config() - + try: positions_list = json.loads(positions) except json.JSONDecodeError as e: print_error(f"Invalid JSON for positions: {e}") sys.exit(1) - + params: dict[str, Any] = { "action": "line_set_positions", "target": target, @@ -221,7 +221,7 @@ def line_set_positions(target: str, positions: str, search_method: Optional[str] } if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -237,7 +237,7 @@ def line_set_positions(target: str, positions: str, search_method: Optional[str] @click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) def line_create_line(target: str, start: Tuple[float, float, float], end: Tuple[float, float, float], search_method: Optional[str]): """Create a simple line between two points. - + \\b Examples: unity-mcp vfx line create-line "MyLine" --start 0 0 0 --end 10 5 0 @@ -251,7 +251,7 @@ def line_create_line(target: str, start: Tuple[float, float, float], end: Tuple[ } if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -268,7 +268,7 @@ def line_create_line(target: str, start: Tuple[float, float, float], end: Tuple[ @click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) def line_create_circle(target: str, center: Tuple[float, float, float], radius: float, segments: int, search_method: Optional[str]): """Create a circle shape. - + \\b Examples: unity-mcp vfx line create-circle "Circle" --radius 5 --segments 64 @@ -284,7 +284,7 @@ def line_create_circle(target: str, center: Tuple[float, float, float], radius: } if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -302,7 +302,7 @@ def line_clear(target: str, search_method: Optional[str]): params: dict[str, Any] = {"action": "line_clear", "target": target} if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -330,7 +330,7 @@ def trail_info(target: str, search_method: Optional[str]): params: dict[str, Any] = {"action": "trail_get_info", "target": target} if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -345,7 +345,7 @@ def trail_info(target: str, search_method: Optional[str]): @click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) def trail_set_time(target: str, duration: float, search_method: Optional[str]): """Set trail duration. - + \\b Examples: unity-mcp vfx trail set-time "PlayerTrail" 2.0 @@ -358,7 +358,7 @@ def trail_set_time(target: str, duration: float, search_method: Optional[str]): } if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -376,7 +376,7 @@ def trail_clear(target: str, search_method: Optional[str]): params: dict[str, Any] = {"action": "trail_clear", "target": target} if search_method: params["searchMethod"] = search_method - + try: result = run_command("manage_vfx", params, config) click.echo(format_output(result, config.format)) @@ -396,16 +396,16 @@ def trail_clear(target: str, search_method: Optional[str]): @click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) def vfx_raw(action: str, target: Optional[str], params: str, search_method: Optional[str]): """Execute any VFX action directly. - + For advanced users who need access to all 60+ VFX actions. - + \\b Actions include: particle_*: particle_set_main, particle_set_emission, particle_set_shape, ... vfx_*: vfx_set_float, vfx_send_event, vfx_play, ... line_*: line_create_arc, line_create_bezier, ... trail_*: trail_set_width, trail_set_color, ... - + \\b Examples: unity-mcp vfx raw particle_set_main "Fire" --params '{"duration": 5, "looping": true}' @@ -413,22 +413,22 @@ def vfx_raw(action: str, target: Optional[str], params: str, search_method: Opti unity-mcp vfx raw vfx_send_event "Explosion" --params '{"eventName": "OnSpawn"}' """ config = get_config() - + try: extra_params = json.loads(params) except json.JSONDecodeError as e: print_error(f"Invalid JSON for params: {e}") sys.exit(1) - + request_params: dict[str, Any] = {"action": action} if target: request_params["target"] = target if search_method: request_params["searchMethod"] = search_method - + # Merge extra params request_params.update(extra_params) - + try: result = run_command("manage_vfx", request_params, config) click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index aa9649278..d18c82a8b 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -69,16 +69,16 @@ def __init__(self): @pass_context def cli(ctx: Context, host: str, port: int, timeout: int, format: str, instance: Optional[str], verbose: bool): """Unity MCP Command Line Interface. - + Control Unity Editor directly from the command line using the Model Context Protocol. - + \b Examples: unity-mcp status unity-mcp gameobject find "Player" unity-mcp scene hierarchy --format json unity-mcp editor play - + \b Environment Variables: UNITY_MCP_HOST Server host (default: 127.0.0.1) @@ -94,10 +94,10 @@ def cli(ctx: Context, host: str, port: int, timeout: int, format: str, instance: format=format, unity_instance=instance, ) - + # Security warning for non-localhost connections warn_if_remote_host(config) - + set_config(config) ctx.config = config ctx.verbose = verbose @@ -108,16 +108,18 @@ def cli(ctx: Context, host: str, port: int, timeout: int, format: str, instance: def status(ctx: Context): """Check connection status to Unity MCP server.""" config = ctx.config or get_config() - + click.echo(f"Checking connection to {config.host}:{config.port}...") - + if run_check_connection(config): - print_success(f"Connected to Unity MCP server at {config.host}:{config.port}") - + print_success( + f"Connected to Unity MCP server at {config.host}:{config.port}") + # Try to get Unity instances try: result = run_list_instances(config) - instances = result.get("instances", []) if isinstance(result, dict) else [] + instances = result.get("instances", []) if isinstance( + result, dict) else [] if instances: click.echo("\nConnected Unity instances:") for inst in instances: @@ -130,7 +132,8 @@ def status(ctx: Context): except UnityConnectionError as e: print_info(f"Could not retrieve Unity instances: {e}") else: - print_error(f"Cannot connect to Unity MCP server at {config.host}:{config.port}") + print_error( + f"Cannot connect to Unity MCP server at {config.host}:{config.port}") sys.exit(1) @@ -139,7 +142,7 @@ def status(ctx: Context): def list_instances(ctx: Context): """List available Unity instances.""" config = ctx.config or get_config() - + try: instances = run_list_instances(config) click.echo(format_output(instances, config.format)) @@ -154,7 +157,7 @@ def list_instances(ctx: Context): @pass_context def raw_command(ctx: Context, command_type: str, params: str): """Send a raw command to Unity. - + \b Examples: unity-mcp raw manage_scene '{"action": "get_hierarchy"}' @@ -162,13 +165,13 @@ def raw_command(ctx: Context, command_type: str, params: str): """ import json config = ctx.config or get_config() - + try: params_dict = json.loads(params) except json.JSONDecodeError as e: print_error(f"Invalid JSON params: {e}") sys.exit(1) - + try: result = run_command(command_type, params_dict, config) click.echo(format_output(result, config.format)) @@ -186,100 +189,100 @@ def register_commands(): cli.add_command(gameobject) except ImportError: pass - + try: from cli.commands.component import component cli.add_command(component) except ImportError: pass - + try: from cli.commands.scene import scene cli.add_command(scene) except ImportError: pass - + try: from cli.commands.asset import asset cli.add_command(asset) except ImportError: pass - + try: from cli.commands.script import script cli.add_command(script) except ImportError: pass - + try: from cli.commands.code import code cli.add_command(code) except ImportError: pass - + try: from cli.commands.editor import editor cli.add_command(editor) except ImportError: pass - + try: from cli.commands.prefab import prefab cli.add_command(prefab) except ImportError: pass - + try: from cli.commands.material import material cli.add_command(material) except ImportError: pass - + try: from cli.commands.lighting import lighting cli.add_command(lighting) except ImportError: pass - + try: from cli.commands.animation import animation cli.add_command(animation) except ImportError: pass - + try: from cli.commands.audio import audio cli.add_command(audio) except ImportError: pass - + try: from cli.commands.ui import ui cli.add_command(ui) except ImportError: pass - + # New commands - instance management try: from cli.commands.instance import instance cli.add_command(instance) except ImportError: pass - + # New commands - shader management try: from cli.commands.shader import shader cli.add_command(shader) except ImportError: pass - + # New commands - VFX management try: from cli.commands.vfx import vfx cli.add_command(vfx) except ImportError: pass - + # New commands - batch execution try: from cli.commands.batch import batch diff --git a/Server/src/cli/utils/config.py b/Server/src/cli/utils/config.py index d6878250f..47299bb0d 100644 --- a/Server/src/cli/utils/config.py +++ b/Server/src/cli/utils/config.py @@ -8,26 +8,28 @@ @dataclass class CLIConfig: """Configuration for CLI connection to Unity.""" - + host: str = "127.0.0.1" port: int = 8080 timeout: int = 30 format: str = "text" # text, json, table unity_instance: Optional[str] = None - + @classmethod def from_env(cls) -> "CLIConfig": port_raw = os.environ.get("UNITY_MCP_HTTP_PORT", "8080") try: port = int(port_raw) except (ValueError, TypeError): - raise ValueError(f"Invalid UNITY_MCP_HTTP_PORT value: {port_raw!r}") + raise ValueError( + f"Invalid UNITY_MCP_HTTP_PORT value: {port_raw!r}") timeout_raw = os.environ.get("UNITY_MCP_TIMEOUT", "30") try: timeout = int(timeout_raw) except (ValueError, TypeError): - raise ValueError(f"Invalid UNITY_MCP_TIMEOUT value: {timeout_raw!r}") + raise ValueError( + f"Invalid UNITY_MCP_TIMEOUT value: {timeout_raw!r}") return cls( host=os.environ.get("UNITY_MCP_HOST", "127.0.0.1"), diff --git a/Server/src/cli/utils/connection.py b/Server/src/cli/utils/connection.py index e107c5a8d..33924e87b 100644 --- a/Server/src/cli/utils/connection.py +++ b/Server/src/cli/utils/connection.py @@ -17,15 +17,15 @@ class UnityConnectionError(Exception): def warn_if_remote_host(config: CLIConfig) -> None: """Warn user if connecting to a non-localhost server. - + This is a security measure to alert users that connecting to remote servers exposes Unity control to potential network attacks. - + Args: config: CLI configuration with host setting """ import click - + local_hosts = ("127.0.0.1", "localhost", "::1", "0.0.0.0") if config.host.lower() not in local_hosts: click.echo( @@ -44,30 +44,30 @@ async def send_command( timeout: Optional[int] = None, ) -> Dict[str, Any]: """Send a command to Unity via the MCP HTTP server. - + Args: command_type: The command type (e.g., 'manage_gameobject', 'manage_scene') params: Command parameters config: Optional CLI configuration timeout: Optional timeout override - + Returns: Response dict from Unity - + Raises: UnityConnectionError: If connection fails """ cfg = config or get_config() url = f"http://{cfg.host}:{cfg.port}/api/command" - + payload = { "type": command_type, "params": params, } - + if cfg.unity_instance: payload["unity_instance"] = cfg.unity_instance - + try: async with httpx.AsyncClient() as client: response = await client.post( @@ -103,13 +103,13 @@ def run_command( timeout: Optional[int] = None, ) -> Dict[str, Any]: """Synchronous wrapper for send_command. - + Args: command_type: The command type params: Command parameters config: Optional CLI configuration timeout: Optional timeout override - + Returns: Response dict from Unity """ @@ -118,16 +118,16 @@ def run_command( async def check_connection(config: Optional[CLIConfig] = None) -> bool: """Check if we can connect to the Unity MCP server. - + Args: config: Optional CLI configuration - + Returns: True if connection successful, False otherwise """ cfg = config or get_config() url = f"http://{cfg.host}:{cfg.port}/health" - + try: async with httpx.AsyncClient() as client: response = await client.get(url, timeout=5) @@ -143,21 +143,21 @@ def run_check_connection(config: Optional[CLIConfig] = None) -> bool: async def list_unity_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]: """List available Unity instances. - + Args: config: Optional CLI configuration - + Returns: Dict with list of Unity instances """ cfg = config or get_config() - + # Try the new /api/instances endpoint first, fall back to /plugin/sessions urls_to_try = [ f"http://{cfg.host}:{cfg.port}/api/instances", f"http://{cfg.host}:{cfg.port}/plugin/sessions", ] - + async with httpx.AsyncClient() as client: for url in urls_to_try: try: @@ -181,8 +181,9 @@ async def list_unity_instances(config: Optional[CLIConfig] = None) -> Dict[str, return {"success": True, "instances": instances} except Exception: continue - - raise UnityConnectionError("Failed to list Unity instances: No working endpoint found") + + raise UnityConnectionError( + "Failed to list Unity instances: No working endpoint found") def run_list_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]: diff --git a/Server/src/transport/legacy/unity_connection.py b/Server/src/transport/legacy/unity_connection.py index 951c7ec5f..08e00ca14 100644 --- a/Server/src/transport/legacy/unity_connection.py +++ b/Server/src/transport/legacy/unity_connection.py @@ -246,7 +246,8 @@ def send_command(self, command_type: str, params: dict[str, Any] = None, max_att raise ValueError("MCP call missing command_type") if params is None: return MCPResponse(success=False, error="MCP call received with no parameters (client placeholder?)") - attempts = max(config.max_retries, 5) if max_attempts is None else max_attempts + attempts = max(config.max_retries, + 5) if max_attempts is None else max_attempts base_backoff = max(0.5, config.retry_delay) def read_status_file(target_hash: str | None = None) -> dict | None: @@ -781,7 +782,8 @@ def send_command_with_retry( # Commands that trigger compilation/reload shouldn't retry on disconnect send_max_attempts = None if retry_on_reload else 0 - response = conn.send_command(command_type, params, max_attempts=send_max_attempts) + response = conn.send_command( + command_type, params, max_attempts=send_max_attempts) retries = 0 wait_started = None reason = _extract_response_reason(response) diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index 77a55e405..9e1bb47da 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -178,13 +178,13 @@ def test_format_as_table(self): def test_format_output_dispatch(self): """Test format_output dispatches correctly.""" data = {"key": "value"} - + json_result = format_output(data, "json") assert json.loads(json_result) == data - + text_result = format_output(data, "text") assert "key" in text_result - + table_result = format_output(data, "table") assert "key" in table_result.lower() or "Key" in table_result @@ -306,7 +306,8 @@ def test_instances_command(self, runner, mock_instances_response): def test_raw_command(self, runner, mock_unity_response): """Test raw command.""" with patch("cli.main.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["raw", "test_command", '{"param": "value"}']) + result = runner.invoke( + cli, ["raw", "test_command", '{"param": "value"}']) assert result.exit_code == 0 def test_raw_command_invalid_json(self, runner): @@ -368,13 +369,15 @@ def test_gameobject_modify(self, runner, mock_unity_response): def test_gameobject_delete(self, runner, mock_unity_response): """Test gameobject delete command.""" with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["gameobject", "delete", "OldObject", "--force"]) + result = runner.invoke( + cli, ["gameobject", "delete", "OldObject", "--force"]) assert result.exit_code == 0 def test_gameobject_delete_confirmation(self, runner, mock_unity_response): """Test gameobject delete with confirmation prompt.""" with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["gameobject", "delete", "OldObject"], input="y\n") + result = runner.invoke( + cli, ["gameobject", "delete", "OldObject"], input="y\n") assert result.exit_code == 0 def test_gameobject_duplicate(self, runner, mock_unity_response): @@ -409,19 +412,22 @@ class TestComponentCommands: def test_component_add(self, runner, mock_unity_response): """Test component add command.""" with patch("cli.commands.component.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["component", "add", "Player", "Rigidbody"]) + result = runner.invoke( + cli, ["component", "add", "Player", "Rigidbody"]) assert result.exit_code == 0 def test_component_remove(self, runner, mock_unity_response): """Test component remove command.""" with patch("cli.commands.component.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["component", "remove", "Player", "Rigidbody", "--force"]) + result = runner.invoke( + cli, ["component", "remove", "Player", "Rigidbody", "--force"]) assert result.exit_code == 0 def test_component_set(self, runner, mock_unity_response): """Test component set command.""" with patch("cli.commands.component.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["component", "set", "Player", "Rigidbody", "mass", "5.0"]) + result = runner.invoke( + cli, ["component", "set", "Player", "Rigidbody", "mass", "5.0"]) assert result.exit_code == 0 def test_component_modify(self, runner, mock_unity_response): @@ -466,7 +472,8 @@ def test_scene_active(self, runner, mock_unity_response): def test_scene_load(self, runner, mock_unity_response): """Test scene load command.""" with patch("cli.commands.scene.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["scene", "load", "Assets/Scenes/Main.unity"]) + result = runner.invoke( + cli, ["scene", "load", "Assets/Scenes/Main.unity"]) assert result.exit_code == 0 def test_scene_save(self, runner, mock_unity_response): @@ -484,7 +491,8 @@ def test_scene_create(self, runner, mock_unity_response): def test_scene_screenshot(self, runner, mock_unity_response): """Test scene screenshot command.""" with patch("cli.commands.scene.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["scene", "screenshot", "--filename", "test"]) + result = runner.invoke( + cli, ["scene", "screenshot", "--filename", "test"]) assert result.exit_code == 0 @@ -504,19 +512,22 @@ def test_asset_search(self, runner, mock_unity_response): def test_asset_info(self, runner, mock_unity_response): """Test asset info command.""" with patch("cli.commands.asset.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["asset", "info", "Assets/Materials/Red.mat"]) + result = runner.invoke( + cli, ["asset", "info", "Assets/Materials/Red.mat"]) assert result.exit_code == 0 def test_asset_create(self, runner, mock_unity_response): """Test asset create command.""" with patch("cli.commands.asset.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["asset", "create", "Assets/Materials/New.mat", "Material"]) + result = runner.invoke( + cli, ["asset", "create", "Assets/Materials/New.mat", "Material"]) assert result.exit_code == 0 def test_asset_delete(self, runner, mock_unity_response): """Test asset delete command.""" with patch("cli.commands.asset.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["asset", "delete", "Assets/Old.mat", "--force"]) + result = runner.invoke( + cli, ["asset", "delete", "Assets/Old.mat", "--force"]) assert result.exit_code == 0 def test_asset_duplicate(self, runner, mock_unity_response): @@ -592,7 +603,8 @@ def test_editor_add_tag(self, runner, mock_unity_response): def test_editor_add_layer(self, runner, mock_unity_response): """Test editor add-layer command.""" with patch("cli.commands.editor.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["editor", "add-layer", "Interactable"]) + result = runner.invoke( + cli, ["editor", "add-layer", "Interactable"]) assert result.exit_code == 0 def test_editor_menu(self, runner, mock_unity_response): @@ -604,7 +616,8 @@ def test_editor_menu(self, runner, mock_unity_response): def test_editor_tests(self, runner, mock_unity_response): """Test editor tests command.""" with patch("cli.commands.editor.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["editor", "tests", "--mode", "EditMode"]) + result = runner.invoke( + cli, ["editor", "tests", "--mode", "EditMode"]) assert result.exit_code == 0 @@ -618,7 +631,8 @@ class TestPrefabCommands: def test_prefab_open(self, runner, mock_unity_response): """Test prefab open command.""" with patch("cli.commands.prefab.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["prefab", "open", "Assets/Prefabs/Player.prefab"]) + result = runner.invoke( + cli, ["prefab", "open", "Assets/Prefabs/Player.prefab"]) assert result.exit_code == 0 def test_prefab_close(self, runner, mock_unity_response): @@ -652,13 +666,15 @@ class TestMaterialCommands: def test_material_info(self, runner, mock_unity_response): """Test material info command.""" with patch("cli.commands.material.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["material", "info", "Assets/Materials/Red.mat"]) + result = runner.invoke( + cli, ["material", "info", "Assets/Materials/Red.mat"]) assert result.exit_code == 0 def test_material_create(self, runner, mock_unity_response): """Test material create command.""" with patch("cli.commands.material.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["material", "create", "Assets/Materials/New.mat"]) + result = runner.invoke( + cli, ["material", "create", "Assets/Materials/New.mat"]) assert result.exit_code == 0 def test_material_set_color(self, runner, mock_unity_response): @@ -698,7 +714,8 @@ class TestScriptCommands: def test_script_create(self, runner, mock_unity_response): """Test script create command.""" with patch("cli.commands.script.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["script", "create", "PlayerController"]) + result = runner.invoke( + cli, ["script", "create", "PlayerController"]) assert result.exit_code == 0 def test_script_create_with_options(self, runner, mock_unity_response): @@ -718,13 +735,15 @@ def test_script_read(self, runner): "data": {"content": "using UnityEngine;\n\npublic class Test {}"} } with patch("cli.commands.script.run_command", return_value=mock_response): - result = runner.invoke(cli, ["script", "read", "Assets/Scripts/Test.cs"]) + result = runner.invoke( + cli, ["script", "read", "Assets/Scripts/Test.cs"]) assert result.exit_code == 0 def test_script_delete(self, runner, mock_unity_response): """Test script delete command.""" with patch("cli.commands.script.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["script", "delete", "Assets/Scripts/Old.cs", "--force"]) + result = runner.invoke( + cli, ["script", "delete", "Assets/Scripts/Old.cs", "--force"]) assert result.exit_code == 0 @@ -739,7 +758,8 @@ def test_custom_host(self, runner, mock_unity_response): """Test custom host option.""" with patch("cli.main.run_check_connection", return_value=True): with patch("cli.main.run_list_instances", return_value={"instances": []}): - result = runner.invoke(cli, ["--host", "192.168.1.100", "status"]) + result = runner.invoke( + cli, ["--host", "192.168.1.100", "status"]) assert result.exit_code == 0 def test_custom_port(self, runner, mock_unity_response): @@ -752,13 +772,15 @@ def test_custom_port(self, runner, mock_unity_response): def test_json_format(self, runner, mock_unity_response): """Test JSON output format.""" with patch("cli.commands.scene.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["--format", "json", "scene", "active"]) + result = runner.invoke( + cli, ["--format", "json", "scene", "active"]) assert result.exit_code == 0 def test_table_format(self, runner, mock_unity_response): """Test table output format.""" with patch("cli.commands.scene.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["--format", "table", "scene", "active"]) + result = runner.invoke( + cli, ["--format", "table", "scene", "active"]) assert result.exit_code == 0 def test_timeout_option(self, runner, mock_unity_response): @@ -824,18 +846,21 @@ def test_full_gameobject_workflow(self, runner): # Create with patch("cli.commands.gameobject.run_command", return_value=create_response): - result = runner.invoke(cli, ["gameobject", "create", "TestObject", "--primitive", "Cube"]) + result = runner.invoke( + cli, ["gameobject", "create", "TestObject", "--primitive", "Cube"]) assert result.exit_code == 0 assert "Created" in result.output # Modify with patch("cli.commands.gameobject.run_command", return_value=modify_response): - result = runner.invoke(cli, ["gameobject", "modify", "TestObject", "--position", "0", "5", "0"]) + result = runner.invoke( + cli, ["gameobject", "modify", "TestObject", "--position", "0", "5", "0"]) assert result.exit_code == 0 # Delete with patch("cli.commands.gameobject.run_command", return_value=delete_response): - result = runner.invoke(cli, ["gameobject", "delete", "TestObject", "--force"]) + result = runner.invoke( + cli, ["gameobject", "delete", "TestObject", "--force"]) assert result.exit_code == 0 assert "Deleted" in result.output @@ -846,7 +871,8 @@ def test_scene_hierarchy_with_data(self, runner): "data": { "nodes": [ {"name": "Main Camera", "instanceID": -100, "childCount": 0}, - {"name": "Directional Light", "instanceID": -200, "childCount": 0}, + {"name": "Directional Light", + "instanceID": -200, "childCount": 0}, {"name": "Player", "instanceID": -300, "childCount": 2}, ] } @@ -884,7 +910,8 @@ def test_instance_list(self, runner): """Test listing Unity instances.""" mock_instances = { "instances": [ - {"project": "TestProject", "hash": "abc123", "unity_version": "2022.3.10f1", "session_id": "sess-1"} + {"project": "TestProject", "hash": "abc123", + "unity_version": "2022.3.10f1", "session_id": "sess-1"} ] } with patch("cli.commands.instance.run_list_instances", return_value=mock_instances): @@ -895,7 +922,8 @@ def test_instance_list(self, runner): def test_instance_set(self, runner, mock_unity_response): """Test setting active instance.""" with patch("cli.commands.instance.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["instance", "set", "TestProject@abc123"]) + result = runner.invoke( + cli, ["instance", "set", "TestProject@abc123"]) assert result.exit_code == 0 def test_instance_current(self, runner): @@ -920,19 +948,22 @@ def test_shader_read(self, runner): "data": {"contents": "Shader \"Custom/Test\" { ... }"} } with patch("cli.commands.shader.run_command", return_value=read_response): - result = runner.invoke(cli, ["shader", "read", "Assets/Shaders/Test.shader"]) + result = runner.invoke( + cli, ["shader", "read", "Assets/Shaders/Test.shader"]) assert result.exit_code == 0 def test_shader_create(self, runner, mock_unity_response): """Test creating a shader.""" with patch("cli.commands.shader.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["shader", "create", "NewShader", "--path", "Assets/Shaders"]) + result = runner.invoke( + cli, ["shader", "create", "NewShader", "--path", "Assets/Shaders"]) assert result.exit_code == 0 def test_shader_delete(self, runner, mock_unity_response): """Test deleting a shader.""" with patch("cli.commands.shader.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["shader", "delete", "Assets/Shaders/Old.shader", "--force"]) + result = runner.invoke( + cli, ["shader", "delete", "Assets/Shaders/Old.shader", "--force"]) assert result.exit_code == 0 @@ -970,13 +1001,15 @@ def test_vfx_line_info(self, runner, mock_unity_response): def test_vfx_line_create_line(self, runner, mock_unity_response): """Test creating a line.""" with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["vfx", "line", "create-line", "Line", "--start", "0", "0", "0", "--end", "10", "5", "0"]) + result = runner.invoke( + cli, ["vfx", "line", "create-line", "Line", "--start", "0", "0", "0", "--end", "10", "5", "0"]) assert result.exit_code == 0 def test_vfx_line_create_circle(self, runner, mock_unity_response): """Test creating a circle.""" with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["vfx", "line", "create-circle", "Circle", "--radius", "5"]) + result = runner.invoke( + cli, ["vfx", "line", "create-circle", "Circle", "--radius", "5"]) assert result.exit_code == 0 def test_vfx_trail_info(self, runner, mock_unity_response): @@ -988,18 +1021,21 @@ def test_vfx_trail_info(self, runner, mock_unity_response): def test_vfx_trail_set_time(self, runner, mock_unity_response): """Test setting trail time.""" with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["vfx", "trail", "set-time", "Trail", "2.0"]) + result = runner.invoke( + cli, ["vfx", "trail", "set-time", "Trail", "2.0"]) assert result.exit_code == 0 def test_vfx_raw(self, runner, mock_unity_response): """Test raw VFX action.""" with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["vfx", "raw", "particle_set_main", "Fire", "--params", '{"duration": 5}']) + result = runner.invoke( + cli, ["vfx", "raw", "particle_set_main", "Fire", "--params", '{"duration": 5}']) assert result.exit_code == 0 def test_vfx_raw_invalid_json(self, runner): """Test raw VFX action with invalid JSON.""" - result = runner.invoke(cli, ["vfx", "raw", "particle_set_main", "Fire", "--params", "invalid json"]) + result = runner.invoke( + cli, ["vfx", "raw", "particle_set_main", "Fire", "--params", "invalid json"]) assert result.exit_code == 1 assert "Invalid JSON" in result.output @@ -1018,7 +1054,8 @@ def test_batch_inline(self, runner, mock_unity_response): "data": {"results": [{"success": True}]} } with patch("cli.commands.batch.run_command", return_value=batch_response): - result = runner.invoke(cli, ["batch", "inline", '[{"tool": "manage_scene", "params": {"action": "get_active"}}]']) + result = runner.invoke( + cli, ["batch", "inline", '[{"tool": "manage_scene", "params": {"action": "get_active"}}]']) assert result.exit_code == 0 def test_batch_inline_invalid_json(self, runner): @@ -1042,8 +1079,9 @@ def test_batch_run_file(self, runner, tmp_path, mock_unity_response): """Test running batch from file.""" # Create a temp batch file batch_file = tmp_path / "commands.json" - batch_file.write_text('[{"tool": "manage_scene", "params": {"action": "get_active"}}]') - + batch_file.write_text( + '[{"tool": "manage_scene", "params": {"action": "get_active"}}]') + batch_response = { "success": True, "data": {"results": [{"success": True}]} @@ -1081,12 +1119,14 @@ def test_editor_custom_tool(self, runner, mock_unity_response): def test_editor_custom_tool_with_params(self, runner, mock_unity_response): """Test executing custom tool with parameters.""" with patch("cli.commands.editor.run_command", return_value=mock_unity_response): - result = runner.invoke(cli, ["editor", "custom-tool", "BuildTool", "--params", '{"target": "Android"}']) + result = runner.invoke( + cli, ["editor", "custom-tool", "BuildTool", "--params", '{"target": "Android"}']) assert result.exit_code == 0 def test_editor_custom_tool_invalid_json(self, runner): """Test custom tool with invalid JSON params.""" - result = runner.invoke(cli, ["editor", "custom-tool", "MyTool", "--params", "bad json"]) + result = runner.invoke( + cli, ["editor", "custom-tool", "MyTool", "--params", "bad json"]) assert result.exit_code == 1 assert "Invalid JSON" in result.output @@ -1112,7 +1152,8 @@ def test_editor_poll_test(self, runner): } } with patch("cli.commands.editor.run_command", return_value=poll_response): - result = runner.invoke(cli, ["editor", "poll-test", "test-job-123"]) + result = runner.invoke( + cli, ["editor", "poll-test", "test-job-123"]) assert result.exit_code == 0 @@ -1137,7 +1178,8 @@ def test_code_search(self, runner): } } with patch("cli.commands.code.run_command", return_value=read_response): - result = runner.invoke(cli, ["code", "search", "class.*Player", "Assets/Scripts/Player.cs"]) + result = runner.invoke( + cli, ["code", "search", "class.*Player", "Assets/Scripts/Player.cs"]) assert result.exit_code == 0 assert "Line 3" in result.output assert "class Player" in result.output @@ -1155,7 +1197,8 @@ def test_code_search_no_matches(self, runner): } } with patch("cli.commands.code.run_command", return_value=read_response): - result = runner.invoke(cli, ["code", "search", "nonexistent", "Assets/Scripts/Test.cs"]) + result = runner.invoke( + cli, ["code", "search", "nonexistent", "Assets/Scripts/Test.cs"]) assert result.exit_code == 0 assert "No matches" in result.output @@ -1172,7 +1215,8 @@ def test_code_search_with_options(self, runner): } } with patch("cli.commands.code.run_command", return_value=read_response): - result = runner.invoke(cli, ["code", "search", "TODO", "Assets/Utils.cs", "--max-results", "100", "--case-sensitive"]) + result = runner.invoke( + cli, ["code", "search", "TODO", "Assets/Utils.cs", "--max-results", "100", "--case-sensitive"]) assert result.exit_code == 0 assert "Line 1" in result.output diff --git a/prune_tool_results.py b/prune_tool_results.py index a3c5d7a4f..a99aae0a1 100755 --- a/prune_tool_results.py +++ b/prune_tool_results.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -import sys, json +import sys +import json + def summarize(txt): try: @@ -7,52 +9,61 @@ def summarize(txt): except Exception: return f"tool_result: {len(txt)} bytes" data = obj.get("data", {}) or {} - msg = obj.get("message") or obj.get("status") or "" + msg = obj.get("message") or obj.get("status") or "" # Common tool shapes if "sha256" in str(data): - ln = data.get("lengthBytes") or data.get("length") or "" + ln = data.get("lengthBytes") or data.get("length") or "" return f"len={ln}".strip() if "diagnostics" in data: diags = data["diagnostics"] or [] - w = sum(d.get("severity","" ).lower()=="warning" for d in diags) - e = sum(d.get("severity","" ).lower() in ("error","fatal") for d in diags) + w = sum(d.get("severity", "").lower() == "warning" for d in diags) + e = sum(d.get("severity", "").lower() in ("error", "fatal") + for d in diags) ok = "OK" if not e else "FAIL" return f"validate: {ok} (warnings={w}, errors={e})" if "matches" in data: m = data["matches"] or [] if m: first = m[0] - return f"find_in_file: {len(m)} match(es) first@{first.get('line',0)}:{first.get('col',0)}" + return f"find_in_file: {len(m)} match(es) first@{first.get('line', 0)}:{first.get('col', 0)}" return "find_in_file: 0 matches" if "lines" in data: # console lines = data["lines"] or [] - lvls = {"info":0,"warning":0,"error":0} + lvls = {"info": 0, "warning": 0, "error": 0} for L in lines: - lvls[L.get("level","" ).lower()] = lvls.get(L.get("level","" ).lower(),0)+1 - return f"console: {len(lines)} lines (info={lvls.get('info',0)},warn={lvls.get('warning',0)},err={lvls.get('error',0)})" + lvls[L.get("level", "").lower()] = lvls.get( + L.get("level", "").lower(), 0)+1 + return f"console: {len(lines)} lines (info={lvls.get('info', 0)},warn={lvls.get('warning', 0)},err={lvls.get('error', 0)})" # Fallback: short status return (msg or "tool_result")[:80] + def prune_message(msg): - if "content" not in msg: return msg - newc=[] + if "content" not in msg: + return msg + newc = [] for c in msg["content"]: - if c.get("type")=="tool_result" and c.get("content"): - out=[] + if c.get("type") == "tool_result" and c.get("content"): + out = [] for chunk in c["content"]: - if chunk.get("type")=="text": - out.append({"type":"text","text":summarize(chunk.get("text","" ))}) - newc.append({"type":"tool_result","tool_use_id":c.get("tool_use_id"),"content":out}) + if chunk.get("type") == "text": + out.append( + {"type": "text", "text": summarize(chunk.get("text", ""))}) + newc.append({"type": "tool_result", "tool_use_id": c.get( + "tool_use_id"), "content": out}) else: newc.append(c) - msg["content"]=newc + msg["content"] = newc return msg + def main(): - convo=json.load(sys.stdin) + convo = json.load(sys.stdin) if isinstance(convo, dict) and "messages" in convo: - convo["messages"]=[prune_message(m) for m in convo["messages"]] + convo["messages"] = [prune_message(m) for m in convo["messages"]] elif isinstance(convo, list): - convo=[prune_message(m) for m in convo] + convo = [prune_message(m) for m in convo] json.dump(convo, sys.stdout, ensure_ascii=False) + + main() From 84242d34fec0ecc61b5eacd79db9181d445b2f1b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 17:26:36 -0400 Subject: [PATCH 14/24] Minor tweak in docs --- docs/CLI_USAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CLI_USAGE.md b/docs/CLI_USAGE.md index aa00949c0..e886ea5e3 100644 --- a/docs/CLI_USAGE.md +++ b/docs/CLI_USAGE.md @@ -1,6 +1,6 @@ # Unity MCP CLI Usage Guide -The Unity MCP CLI provides command-line access to control Unity Editor through the Model Context Protocol. Now only support Local HTTP. +The Unity MCP CLI provides command-line access to control Unity Editor through the Model Context Protocol. Currently only supports local HTTP. Note: Some tools are still experimental and might fail under circumstances. Please submit an issue for us to make it better. From 593f3d71e3cc9ff851475ea25f03b9eb6f941f08 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 17:26:44 -0400 Subject: [PATCH 15/24] Use `wait` params --- Server/src/cli/commands/editor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index bdee3cfd0..c76e580f3 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -310,6 +310,8 @@ def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, f config = get_config() params: dict[str, Any] = {"mode": mode} + if wait is not None: + params["wait_timeout"] = wait if details: params["include_details"] = True if failed_only: From 1153b860fa81d2e9717b63ecd901003a16156c61 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 17:36:42 -0400 Subject: [PATCH 16/24] Unrelated but project scoped tools should be off by default --- .../Windows/Components/Connection/McpConnectionSection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index f54dcb8f6..1c391f608 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -132,7 +132,7 @@ private void InitializeUI() { projectScopedToolsToggle.value = EditorPrefs.GetBool( EditorPrefKeys.ProjectScopedToolsLocalHttp, - true + false ); } From ca3ddcc32201b0a742aecc7b5c233031ec2f7f9a Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 17:44:37 -0400 Subject: [PATCH 17/24] Update lock file --- Server/uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/uv.lock b/Server/uv.lock index 6af0a2b03..48e2c5641 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -912,7 +912,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "9.0.3" +version = "9.0.8" source = { editable = "." } dependencies = [ { name = "click" }, From 4e94030049ae32cd603c2d0511fa67bacf929d8c Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 18:13:47 -0400 Subject: [PATCH 18/24] Whitespace cleanup --- Server/src/main.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/Server/src/main.py b/Server/src/main.py index f2cab94ea..a063ffaf3 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -1,3 +1,18 @@ +from starlette.requests import Request +from transport.unity_instance_middleware import ( + UnityInstanceMiddleware, + get_unity_instance_middleware +) +from transport.legacy.unity_connection import get_unity_connection_pool, UnityConnectionPool +from services.tools import register_all_tools +from core.telemetry import record_milestone, record_telemetry, MilestoneType, RecordType, get_package_version +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 core.config import config +from starlette.routing import WebSocketRoute +from starlette.responses import JSONResponse import argparse import asyncio import logging @@ -50,22 +65,7 @@ def doRollover(self): # On Windows, another process may have the log file open. # Skip rotation this time - we'll try again on the next rollover. pass -from starlette.requests import Request -from starlette.responses import JSONResponse -from starlette.routing import WebSocketRoute -from core.config import config -from services.custom_tool_service import CustomToolService -from transport.plugin_hub import PluginHub -from transport.plugin_registry import PluginRegistry -from services.resources import register_all_resources -from core.telemetry import record_milestone, record_telemetry, MilestoneType, RecordType, get_package_version -from services.tools import register_all_tools -from transport.legacy.unity_connection import get_unity_connection_pool, UnityConnectionPool -from transport.unity_instance_middleware import ( - UnityInstanceMiddleware, - get_unity_instance_middleware -) # Configure logging using settings from config logging.basicConfig( @@ -342,7 +342,7 @@ async def cli_command_route(request: Request) -> JSONResponse: sessions = await PluginHub.get_sessions() if not sessions.sessions: return JSONResponse({ - "success": False, + "success": False, "error": "No Unity instances connected. Make sure Unity is running with MCP plugin." }, status_code=503) @@ -367,7 +367,6 @@ async def cli_command_route(request: Request) -> JSONResponse: logger.error(f"CLI command 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.""" @@ -386,7 +385,6 @@ async def cli_instances_route(_: Request) -> JSONResponse: except Exception as e: return JSONResponse({"success": False, "error": str(e)}, status_code=500) - @mcp.custom_route("/plugin/sessions", methods=["GET"]) async def plugin_sessions_route(_: Request) -> JSONResponse: data = await PluginHub.get_sessions() @@ -532,7 +530,8 @@ def main(): try: _env_port = int(_env_port_str) if _env_port_str is not None else None except ValueError: - logger.warning("Invalid UNITY_MCP_HTTP_PORT value '%s', ignoring", _env_port_str) + logger.warning( + "Invalid UNITY_MCP_HTTP_PORT value '%s', ignoring", _env_port_str) _env_port = None http_port = args.http_port or _env_port or parsed_url.port or 8080 From 4cd75b18d580a83cc460c7796a327b6c13a525ac Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 18:19:59 -0400 Subject: [PATCH 19/24] =?UTF-8?q?Update=20custom=5Ftool=5Fservice.py=20to?= =?UTF-8?q?=20skip=20global=20registration=20for=20any=20tool=20name=20tha?= =?UTF-8?q?t=20already=20exists=20as=20a=20built=E2=80=91in.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Server/src/services/custom_tool_service.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Server/src/services/custom_tool_service.py b/Server/src/services/custom_tool_service.py index 03a0dedeb..9dabaad8a 100644 --- a/Server/src/services/custom_tool_service.py +++ b/Server/src/services/custom_tool_service.py @@ -20,6 +20,7 @@ ) from transport.plugin_hub import PluginHub from services.tools import get_unity_instance_from_context +from services.registry import get_registered_tools logger = logging.getLogger("mcp-for-unity-server") @@ -287,9 +288,19 @@ def _register_project_tools( def register_global_tools(self, tools: list[ToolDefinitionModel]) -> None: if self._project_scoped_tools: return + builtin_names = self._get_builtin_tool_names() for tool in tools: + if tool.name in builtin_names: + logger.info( + "Skipping global custom tool registration for built-in tool '%s'", + tool.name, + ) + continue self._register_global_tool(tool) + def _get_builtin_tool_names(self) -> set[str]: + return {tool["name"] for tool in get_registered_tools()} + def _register_global_tool(self, definition: ToolDefinitionModel) -> None: existing = self._global_tools.get(definition.name) if existing: From 5a2968e67334905ac01ad491e237daef8793c982 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 18:55:32 -0400 Subject: [PATCH 20/24] Avoid silently falling back to the first Unity session when a specific unity_instance was requested but not found. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a client passes a unity_instance that doesn’t match any session, this code will still route the command to the first available session, which can send commands to the wrong project in multi‑instance environments. Instead, when a unity_instance is provided but no matching session_id is found, return an error (e.g. 400/404 with "Unity instance '' not found") and only default to the first session when no unity_instance was specified. Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- Server/src/main.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Server/src/main.py b/Server/src/main.py index a063ffaf3..7755fc675 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -355,8 +355,17 @@ async def cli_command_route(request: Request) -> JSONResponse: session_id = sid break - if not session_id: - # Use first available session + # If a specific unity_instance was requested but not found, return an error + if not session_id: + 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_id = next(iter(sessions.sessions.keys())) # Send command to Unity From 99e2c0298aeb9ae1edba7e17d2817c60facf9b65 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 18:56:01 -0400 Subject: [PATCH 21/24] Update docs/CLI_USAGE.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- docs/CLI_USAGE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CLI_USAGE.md b/docs/CLI_USAGE.md index e886ea5e3..ad106e727 100644 --- a/docs/CLI_USAGE.md +++ b/docs/CLI_USAGE.md @@ -1,8 +1,8 @@ # Unity MCP CLI Usage Guide -The Unity MCP CLI provides command-line access to control Unity Editor through the Model Context Protocol. Currently only supports local HTTP. +The Unity MCP CLI provides command-line access to control the Unity Editor through the Model Context Protocol. It currently only supports local HTTP. -Note: Some tools are still experimental and might fail under circumstances. Please submit an issue for us to make it better. +Note: Some tools are still experimental and might fail under some circumstances. Please submit an issue to help us make it better. ## Installation From bb37c5b3560de0f2e5b3cadbdfae7327d43ed466 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 19:01:35 -0400 Subject: [PATCH 22/24] =?UTF-8?q?Updated=20the=20CLI=20command=20registrat?= =?UTF-8?q?ion=20to=20only=20swallow=20missing=20optional=20modules=20and?= =?UTF-8?q?=20to=20surface=20real=20import-time=20failures,=20so=20broken?= =?UTF-8?q?=20command=20modules=20don=E2=80=99t=20get=20silently=20ignored?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Server/src/cli/main.py | 154 +++++++++++++---------------------------- 1 file changed, 49 insertions(+), 105 deletions(-) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index d18c82a8b..678d3abde 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -1,6 +1,8 @@ """Unity MCP Command Line Interface - Main Entry Point.""" import sys +from importlib import import_module + import click from typing import Optional @@ -184,111 +186,53 @@ def raw_command(ctx: Context, command_type: str, params: str): # These will be implemented in subsequent TODOs def register_commands(): """Register all command groups.""" - try: - from cli.commands.gameobject import gameobject - cli.add_command(gameobject) - except ImportError: - pass - - try: - from cli.commands.component import component - cli.add_command(component) - except ImportError: - pass - - try: - from cli.commands.scene import scene - cli.add_command(scene) - except ImportError: - pass - - try: - from cli.commands.asset import asset - cli.add_command(asset) - except ImportError: - pass - - try: - from cli.commands.script import script - cli.add_command(script) - except ImportError: - pass - - try: - from cli.commands.code import code - cli.add_command(code) - except ImportError: - pass - - try: - from cli.commands.editor import editor - cli.add_command(editor) - except ImportError: - pass - - try: - from cli.commands.prefab import prefab - cli.add_command(prefab) - except ImportError: - pass - - try: - from cli.commands.material import material - cli.add_command(material) - except ImportError: - pass - - try: - from cli.commands.lighting import lighting - cli.add_command(lighting) - except ImportError: - pass - - try: - from cli.commands.animation import animation - cli.add_command(animation) - except ImportError: - pass - - try: - from cli.commands.audio import audio - cli.add_command(audio) - except ImportError: - pass - - try: - from cli.commands.ui import ui - cli.add_command(ui) - except ImportError: - pass - - # New commands - instance management - try: - from cli.commands.instance import instance - cli.add_command(instance) - except ImportError: - pass - - # New commands - shader management - try: - from cli.commands.shader import shader - cli.add_command(shader) - except ImportError: - pass - - # New commands - VFX management - try: - from cli.commands.vfx import vfx - cli.add_command(vfx) - except ImportError: - pass - - # New commands - batch execution - try: - from cli.commands.batch import batch - cli.add_command(batch) - except ImportError: - pass + def register_optional_command(module_name: str, command_name: str) -> None: + try: + module = import_module(module_name) + except ModuleNotFoundError as e: + if e.name == module_name: + return + print_error( + f"Failed to load command module '{module_name}': {e}" + ) + return + except Exception as e: + print_error( + f"Failed to load command module '{module_name}': {e}" + ) + return + + command = getattr(module, command_name, None) + if command is None: + print_error( + f"Command '{command_name}' not found in '{module_name}'" + ) + return + + cli.add_command(command) + + optional_commands = [ + ("cli.commands.gameobject", "gameobject"), + ("cli.commands.component", "component"), + ("cli.commands.scene", "scene"), + ("cli.commands.asset", "asset"), + ("cli.commands.script", "script"), + ("cli.commands.code", "code"), + ("cli.commands.editor", "editor"), + ("cli.commands.prefab", "prefab"), + ("cli.commands.material", "material"), + ("cli.commands.lighting", "lighting"), + ("cli.commands.animation", "animation"), + ("cli.commands.audio", "audio"), + ("cli.commands.ui", "ui"), + ("cli.commands.instance", "instance"), + ("cli.commands.shader", "shader"), + ("cli.commands.vfx", "vfx"), + ("cli.commands.batch", "batch"), + ] + + for module_name, command_name in optional_commands: + register_optional_command(module_name, command_name) # Register commands on import From 60ebf3c4fe9392847168496c0d69da7616695430 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 19:04:40 -0400 Subject: [PATCH 23/24] Sorted __all__ alphabetically to satisfy RUF022 in __init__.py. --- Server/src/cli/utils/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Server/src/cli/utils/__init__.py b/Server/src/cli/utils/__init__.py index 622ccc4fc..54cdad1db 100644 --- a/Server/src/cli/utils/__init__.py +++ b/Server/src/cli/utils/__init__.py @@ -17,15 +17,15 @@ __all__ = [ "CLIConfig", - "get_config", - "set_config", - "run_command", - "run_check_connection", - "run_list_instances", "UnityConnectionError", "format_output", - "print_success", + "get_config", "print_error", - "print_warning", "print_info", + "print_success", + "print_warning", + "run_check_connection", + "run_command", + "run_list_instances", + "set_config", ] From af8a7ad51cda2f9d2c36f5e267d920279124e1d3 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 19:05:17 -0400 Subject: [PATCH 24/24] Validate --params is a JSON object before merging. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- Server/src/cli/commands/vfx.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Server/src/cli/commands/vfx.py b/Server/src/cli/commands/vfx.py index c57a5decc..a03233080 100644 --- a/Server/src/cli/commands/vfx.py +++ b/Server/src/cli/commands/vfx.py @@ -419,6 +419,9 @@ def vfx_raw(action: str, target: Optional[str], params: str, search_method: Opti except json.JSONDecodeError as e: print_error(f"Invalid JSON for params: {e}") sys.exit(1) + if not isinstance(extra_params, dict): + print_error("Invalid JSON for params: expected an object") + sys.exit(1) request_params: dict[str, Any] = {"action": action} if target: @@ -428,7 +431,6 @@ def vfx_raw(action: str, target: Optional[str], params: str, search_method: Opti # Merge extra params request_params.update(extra_params) - try: result = run_command("manage_vfx", request_params, config) click.echo(format_output(result, config.format))