From 3c4c4a6cd916b325052761e78d28d107d6255a6f Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 14:13:27 -0400 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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 09/17] 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 10/17] 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 11/17] 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 12/17] =?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 13/17] 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 14/17] 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 15/17] =?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 16/17] 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 17/17] 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))