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/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 ); } 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..c76e580f3 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,18 @@ 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 wait is not None: + params["wait_timeout"] = wait 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 +327,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 +354,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 +362,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 +370,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 +419,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 +428,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 +436,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 +457,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 +467,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..a03233080 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,24 @@ 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) - + 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: 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..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 @@ -69,16 +71,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 +96,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 +110,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 +134,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 +144,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 +159,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 +167,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)) @@ -181,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 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", ] 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/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}") diff --git a/Server/src/main.py b/Server/src/main.py index f2cab94ea..7755fc675 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) @@ -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 @@ -367,7 +376,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 +394,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 +539,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 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: 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/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" }, diff --git a/docs/CLI_USAGE.md b/docs/CLI_USAGE.md index aa00949c0..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. Now only support 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 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" + ] + } + } +} 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()