diff --git a/.claude/skills/unity-mcp-skill/SKILL.md b/.claude/skills/unity-mcp-skill/SKILL.md index 0c0a11f41..282578d55 100644 --- a/.claude/skills/unity-mcp-skill/SKILL.md +++ b/.claude/skills/unity-mcp-skill/SKILL.md @@ -178,11 +178,11 @@ uri="file:///full/path/to/file.cs" | Category | Key Tools | Use For | |----------|-----------|---------| -| **Scene** | `manage_scene`, `find_gameobjects` | Scene operations, finding objects | +| **Scene** | `manage_scene`, `find_gameobjects` | Scene operations, finding objects. Multi-scene editing (additive load, close, set active, move GO between scenes), scene templates (`3d_basic`, `2d_basic`, `empty`, `default`), scene validation with `auto_repair`. For build settings, use `manage_build(action="scenes")`. | | **Objects** | `manage_gameobject`, `manage_components` | Creating/modifying GameObjects | | **Scripts** | `create_script`, `script_apply_edits`, `validate_script` | C# code management (auto-refreshes on create/edit) | | **Assets** | `manage_asset`, `manage_prefabs` | Asset operations. **Prefab instantiation** is done via `manage_gameobject(action="create", prefab_path="...")`, not `manage_prefabs`. | -| **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control, package deployment (`deploy_package`/`restore_package` actions) | +| **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control, package deployment (`deploy_package`/`restore_package`), undo/redo (`undo`/`redo` actions) | | **Testing** | `run_tests`, `get_test_job` | Unity Test Framework | | **Batch** | `batch_execute` | Parallel/bulk operations | | **Camera** | `manage_camera` | Camera management (Unity Camera + Cinemachine). **Tier 1** (always available): create, target, lens, priority, list, screenshot. **Tier 2** (requires `com.unity.cinemachine`): brain, body/aim/noise pipeline, extensions, blending, force/release. 7 presets: follow, third_person, freelook, dolly, static, top_down, side_scroller. Resource: `mcpforunity://scene/cameras`. Use `ping` to check Cinemachine availability. See [tools-reference.md](references/tools-reference.md#camera-tools). | diff --git a/.claude/skills/unity-mcp-skill/references/tools-reference.md b/.claude/skills/unity-mcp-skill/references/tools-reference.md index 21349c7e1..bc100e25a 100644 --- a/.claude/skills/unity-mcp-skill/references/tools-reference.md +++ b/.claude/skills/unity-mcp-skill/references/tools-reference.md @@ -175,6 +175,26 @@ manage_scene(action="get_build_settings") # Build settings manage_scene(action="create", name="NewScene", path="Assets/Scenes/") manage_scene(action="load", path="Assets/Scenes/Main.unity") manage_scene(action="save") + +# Scene templates — create with preset objects +manage_scene(action="create", name="Level1", template="3d_basic") # Camera + Light + Ground +manage_scene(action="create", name="Level2", template="2d_basic") # Camera (ortho) + Light +manage_scene(action="create", name="Empty", template="empty") # No default objects +manage_scene(action="create", name="Default", template="default") # Camera + Light (Unity default) + +# Multi-scene editing +manage_scene(action="load", path="Assets/Scenes/Level2.unity", additive=True) # Keep current scene +manage_scene(action="get_loaded_scenes") # List all loaded scenes +manage_scene(action="set_active_scene", scene_name="Level2") # Set active scene +manage_scene(action="close_scene", scene_name="Level2") # Unload scene +manage_scene(action="close_scene", scene_name="Level2", remove_scene=True) # Fully remove +manage_scene(action="move_to_scene", target="Player", scene_name="Level2") # Move root GO + +# Build settings — use manage_build(action="scenes") instead + +# Scene validation +manage_scene(action="validate") # Detect missing scripts, broken prefabs +manage_scene(action="validate", auto_repair=True) # Also auto-fix missing scripts (undoable) ``` ### find_gameobjects @@ -332,6 +352,11 @@ manage_components( # - "Assets/Prefabs/My.prefab" → String shorthand for asset paths # - "ObjectName" → String shorthand for scene name lookup # - 12345 → Integer shorthand for instanceID +# +# Sprite sub-asset references (for SpriteRenderer.sprite, Image.sprite, etc.): +# - {"guid": "...", "spriteName": "SubSprite"} → Sprite sub-asset from atlas +# - {"guid": "...", "fileID": 12345} → Sub-asset by fileID +# Single-sprite textures auto-resolve from guid/path alone. ``` --- @@ -523,6 +548,24 @@ manage_prefabs( position=[0, 1, 0], components_to_add=["AudioSource"] ) + +# Add child GameObjects to a prefab (single or batch) +manage_prefabs( + action="modify_contents", + prefab_path="Assets/Prefabs/Player.prefab", + create_child=[ + {"name": "Child1", "primitive_type": "Sphere", "position": [1, 0, 0]}, + {"name": "Child2", "primitive_type": "Cube", "parent": "Child1"} + ] +) + +# Add a nested prefab instance inside a prefab +manage_prefabs( + action="modify_contents", + prefab_path="Assets/Prefabs/Player.prefab", + create_child={"name": "Bullet", "source_prefab_path": "Assets/Prefabs/Bullet.prefab", "position": [0, 2, 0]} +) +# source_prefab_path and primitive_type are mutually exclusive ``` --- @@ -691,7 +734,7 @@ manage_ui( ### manage_editor -Control Unity Editor state. +Control Unity Editor state, undo/redo. ```python manage_editor(action="play") # Enter play mode @@ -708,6 +751,10 @@ manage_editor(action="remove_layer", layer_name="OldLayer") manage_editor(action="close_prefab_stage") # Exit prefab editing mode back to main scene +# Undo/Redo — returns the affected undo group name +manage_editor(action="undo") # Undo last action +manage_editor(action="redo") # Redo last undone action + # Package deployment (no confirmation dialog — designed for LLM-driven iteration) manage_editor(action="deploy_package") # Copy configured MCPForUnity source into installed package manage_editor(action="restore_package") # Revert to pre-deployment backup diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs b/MCPForUnity/Editor/Tools/ManageEditor.cs index 5bfd1972d..0fbc89217 100644 --- a/MCPForUnity/Editor/Tools/ManageEditor.cs +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -145,9 +145,38 @@ public static object HandleCommand(JObject @params) case "restore_package": return RestorePackage(); + // Undo/Redo + case "undo": + { + string groupName = Undo.GetCurrentGroupName(); + Undo.PerformUndo(); + string message = string.IsNullOrEmpty(groupName) + ? "Undo performed (stack may be empty)." + : $"Undid: {groupName}"; + if (EditorApplication.isPlaying) + message += " Warning: undo during play mode may have unexpected effects."; + return new SuccessResponse(message, new + { + undone_group = string.IsNullOrEmpty(groupName) ? (string)null : groupName, + next_group = Undo.GetCurrentGroupName() + }); + } + case "redo": + { + Undo.PerformRedo(); + string nextGroup = Undo.GetCurrentGroupName(); + string message = "Redo performed."; + if (EditorApplication.isPlaying) + message += " Warning: redo during play mode may have unexpected effects."; + return new SuccessResponse(message, new + { + current_group = string.IsNullOrEmpty(nextGroup) ? (string)null : nextGroup + }); + } + default: return new ErrorResponse( - $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, close_prefab_stage, deploy_package, restore_package. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." + $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, close_prefab_stage, deploy_package, restore_package, undo, redo. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." ); } } diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index cdae1f22d..de44c8139 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -54,6 +54,15 @@ private sealed class SceneCommand public int? maxDepth { get; set; } public int? maxChildrenPerNode { get; set; } public bool? includeTransform { get; set; } + + // Multi-scene editing + public string sceneName { get; set; } + public string scenePath { get; set; } + public string target { get; set; } // GO reference for move_to_scene + public bool? removeScene { get; set; } // for close_scene + public bool? additive { get; set; } // for load additive mode + public string template { get; set; } // for create with template + public bool? autoRepair { get; set; } // for validate with auto-repair } private static float[] ParseFloatArray(JToken token) @@ -122,9 +131,31 @@ private static SceneCommand ToSceneCommand(JObject p) maxDepth = ParamCoercion.CoerceIntNullable(p["maxDepth"] ?? p["max_depth"]), maxChildrenPerNode = ParamCoercion.CoerceIntNullable(p["maxChildrenPerNode"] ?? p["max_children_per_node"]), includeTransform = ParamCoercion.CoerceBoolNullable(p["includeTransform"] ?? p["include_transform"]), + + // Multi-scene editing + sceneName = (p["sceneName"] ?? p["scene_name"])?.ToString(), + scenePath = (p["scenePath"] ?? p["scene_path"])?.ToString(), + target = (p["target"])?.ToString(), + removeScene = ParamCoercion.CoerceBoolNullable(p["removeScene"] ?? p["remove_scene"]), + additive = ParamCoercion.CoerceBoolNullable(p["additive"]), + template = (p["template"])?.ToString()?.ToLowerInvariant(), + autoRepair = ParamCoercion.CoerceBoolNullable(p["autoRepair"] ?? p["auto_repair"]), }; } + private static Scene? FindLoadedScene(string sceneName, string scenePath) + { + for (int i = 0; i < SceneManager.sceneCount; i++) + { + var scene = SceneManager.GetSceneAt(i); + if (!string.IsNullOrEmpty(scenePath) && scene.path == scenePath) + return scene; + if (!string.IsNullOrEmpty(sceneName) && scene.name == sceneName) + return scene; + } + return null; + } + /// /// Main handler for scene management actions. /// @@ -191,15 +222,27 @@ public static object HandleCommand(JObject @params) switch (action) { case "create": - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) + if (string.IsNullOrEmpty(name)) return new ErrorResponse( - "'name' and 'path' parameters are required for 'create' action." + "'name' parameter is required for 'create' action. 'path' is optional (defaults to 'Assets/Scenes/')." ); + if (!string.IsNullOrEmpty(cmd.template)) + return CreateSceneFromTemplate(fullPath, relativePath, cmd.template); return CreateScene(fullPath, relativePath); case "load": // Loading can be done by path/name or build index - if (!string.IsNullOrEmpty(relativePath)) - return LoadScene(relativePath); + // When path ends with .unity and no name is given, use path directly as the scene path + string loadPath = relativePath; + if (string.IsNullOrEmpty(loadPath) && !string.IsNullOrEmpty(path)) + loadPath = AssetPathUtility.NormalizeSeparators( + path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) + ? path : "Assets/" + path); + if (!string.IsNullOrEmpty(loadPath)) + { + if (cmd.additive == true) + return LoadSceneAdditive(loadPath); + return LoadScene(loadPath); + } else if (buildIndex.HasValue) return LoadScene(buildIndex.Value); else @@ -225,9 +268,28 @@ public static object HandleCommand(JObject @params) return CaptureScreenshot(cmd); case "scene_view_frame": return FrameSceneView(cmd); + + // Multi-scene editing + case "close_scene": + return CloseScene(cmd); + case "set_active_scene": + return SetActiveScene(cmd); + case "get_loaded_scenes": + return GetLoadedScenes(); + case "move_to_scene": + return MoveToScene(cmd); + case "modify_build_settings": + return new ErrorResponse( + "Build settings management has moved to manage_build (action='scenes'). " + + "Use manage_build to add, remove, or configure scenes in build settings."); + + // Scene validation + case "validate": + return ValidateScene(cmd.autoRepair == true); + default: return new ErrorResponse( - $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings, screenshot, scene_view_frame." + $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings, screenshot, scene_view_frame, close_scene, set_active_scene, get_loaded_scenes, move_to_scene, validate. For build settings, use manage_build." ); } } @@ -1446,6 +1508,252 @@ private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath, EditorApplication.update += tick; } + // ── Multi-scene editing ──────────────────────────────────────────── + + private static object LoadSceneAdditive(string scenePath) + { + string projectRoot = Application.dataPath.Substring(0, Application.dataPath.Length - "Assets".Length); + if (!File.Exists(Path.Combine(projectRoot, scenePath))) + return new ErrorResponse($"Scene not found: '{scenePath}'"); + + var existing = SceneManager.GetSceneByPath(scenePath); + if (existing.IsValid() && existing.isLoaded) + return new ErrorResponse($"Scene '{existing.name}' is already loaded."); + + var scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Additive); + return new SuccessResponse($"Opened '{scene.name}' additively.", new + { + sceneName = scene.name, + scenePath = scene.path, + loadedSceneCount = SceneManager.sceneCount + }); + } + + private static object CloseScene(SceneCommand cmd) + { + var scene = FindLoadedScene(cmd.sceneName ?? cmd.name, cmd.scenePath); + if (!scene.HasValue) + return new ErrorResponse("Scene not found among loaded scenes. Provide 'sceneName' or 'scenePath'."); + + if (SceneManager.sceneCount <= 1) + return new ErrorResponse("Cannot close the last loaded scene."); + + if (scene.Value.isDirty) + return new ErrorResponse($"Scene '{scene.Value.name}' has unsaved changes. Save first or data will be lost."); + + string capturedName = scene.Value.name; + bool remove = cmd.removeScene ?? false; + bool closed = EditorSceneManager.CloseScene(scene.Value, remove); + string verb = remove ? "Removed" : "Unloaded"; + if (!closed) + return new ErrorResponse($"Failed to {verb.ToLowerInvariant()} scene '{capturedName}'."); + return new SuccessResponse($"{verb} scene '{capturedName}'.", new + { + sceneName = capturedName, + removed = remove, + loadedSceneCount = SceneManager.sceneCount + }); + } + + private static object SetActiveScene(SceneCommand cmd) + { + var scene = FindLoadedScene(cmd.sceneName ?? cmd.name, cmd.scenePath); + if (!scene.HasValue) + return new ErrorResponse("Scene not found among loaded scenes. Provide 'sceneName' or 'scenePath'."); + if (!scene.Value.isLoaded) + return new ErrorResponse($"Scene '{scene.Value.name}' is not loaded. Open it first."); + + string capturedName = scene.Value.name; + bool success = SceneManager.SetActiveScene(scene.Value); + if (!success) + return new ErrorResponse($"Failed to set '{capturedName}' as the active scene."); + return new SuccessResponse($"Set '{capturedName}' as the active scene."); + } + + private static object GetLoadedScenes() + { + var activeScene = SceneManager.GetActiveScene(); + var scenes = new List(); + for (int i = 0; i < SceneManager.sceneCount; i++) + { + var s = SceneManager.GetSceneAt(i); + scenes.Add(new + { + name = s.name, + path = s.path, + buildIndex = s.buildIndex, + isLoaded = s.isLoaded, + isDirty = s.isDirty, + isActive = s == activeScene, + rootCount = s.isLoaded ? s.rootCount : 0 + }); + } + return new SuccessResponse($"{scenes.Count} scene(s) loaded.", new { scenes }); + } + + private static object MoveToScene(SceneCommand cmd) + { + if (string.IsNullOrEmpty(cmd.target)) + return new ErrorResponse("'target' (GameObject name/path/instanceID) is required for move_to_scene."); + + var go = ResolveGameObject(new JValue(cmd.target), SceneManager.GetActiveScene()); + if (go == null) + return new ErrorResponse($"GameObject not found: '{cmd.target}'"); + if (go.transform.parent != null) + return new ErrorResponse($"'{go.name}' is not a root GameObject. Only root objects can be moved between scenes."); + + var targetScene = FindLoadedScene(cmd.sceneName ?? cmd.name, cmd.scenePath); + if (!targetScene.HasValue) + return new ErrorResponse("Target scene not found. Provide 'sceneName' or 'scenePath'."); + if (!targetScene.Value.isLoaded) + return new ErrorResponse($"Target scene '{targetScene.Value.name}' is not loaded."); + + SceneManager.MoveGameObjectToScene(go, targetScene.Value); + return new SuccessResponse($"Moved '{go.name}' to scene '{targetScene.Value.name}'."); + } + + // ModifyBuildSettings removed — use manage_build(action="scenes") instead. + + // ── Scene templates ──────────────────────────────────────────────── + + private static object CreateSceneFromTemplate(string fullPath, string relativePath, string template) + { + NewSceneSetup setup; + switch (template) + { + case "empty": + setup = NewSceneSetup.EmptyScene; + break; + case "default": + case "3d_basic": + case "2d_basic": + setup = NewSceneSetup.DefaultGameObjects; + break; + default: + return new ErrorResponse( + $"Unknown template: '{template}'. Valid: empty, default, 3d_basic, 2d_basic."); + } + + if (!string.IsNullOrEmpty(fullPath) && File.Exists(fullPath)) + return new ErrorResponse($"Scene already exists at '{relativePath}'. Delete it first or use a different name."); + + var scene = EditorSceneManager.NewScene(setup, NewSceneMode.Single); + + if (template == "3d_basic") + { + var plane = GameObject.CreatePrimitive(PrimitiveType.Plane); + plane.name = "Ground"; + plane.transform.position = Vector3.zero; + } + else if (template == "2d_basic") + { + var cam = Camera.main; + if (cam != null) + cam.orthographic = true; + } + + if (!string.IsNullOrEmpty(fullPath) && !string.IsNullOrEmpty(relativePath)) + { + string dir = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + if (!EditorSceneManager.SaveScene(scene, relativePath)) + return new ErrorResponse($"Scene created in memory but failed to save to '{relativePath}'."); + } + + return new SuccessResponse($"Created scene from template '{template}'.", new + { + sceneName = scene.name, + scenePath = scene.path, + template, + rootObjectCount = scene.rootCount + }); + } + + // ── Scene validation ─────────────────────────────────────────────── + + private static object ValidateScene(bool autoRepair) + { + var activeScene = SceneManager.GetActiveScene(); + var rootObjects = activeScene.GetRootGameObjects(); + + int missingScripts = 0; + int brokenPrefabs = 0; + int repaired = 0; + var issues = new List(); + const int maxIssues = 200; + + foreach (var root in rootObjects) + { + var allTransforms = root.GetComponentsInChildren(true); + foreach (var t in allTransforms) + { + var go = t.gameObject; + + int missing = GameObjectUtility.GetMonoBehavioursWithMissingScriptCount(go); + if (missing > 0) + { + missingScripts += missing; + if (issues.Count < maxIssues) + { + issues.Add(new + { + type = "missing_script", + gameObject = go.name, + path = GetGameObjectPath(go), + count = missing + }); + } + + if (autoRepair) + { + Undo.RegisterCompleteObjectUndo(go, "Remove Missing Scripts"); + repaired += GameObjectUtility.RemoveMonoBehavioursWithMissingScript(go); + } + } + + var prefabStatus = PrefabUtility.GetPrefabInstanceStatus(go); + if (prefabStatus == PrefabInstanceStatus.MissingAsset || + prefabStatus == PrefabInstanceStatus.Disconnected) + { + brokenPrefabs++; + if (issues.Count < maxIssues) + { + issues.Add(new + { + type = "broken_prefab", + gameObject = go.name, + path = GetGameObjectPath(go), + status = prefabStatus.ToString() + }); + } + } + } + } + + if (repaired > 0) + EditorSceneManager.MarkSceneDirty(activeScene); + + int totalIssues = missingScripts + brokenPrefabs; + string message = totalIssues == 0 + ? $"Scene '{activeScene.name}' is clean — no issues found." + : $"Scene '{activeScene.name}' has {totalIssues} issue(s)."; + if (repaired > 0) + message += $" Auto-repaired {repaired} missing script(s). Use undo to revert."; + + return new SuccessResponse(message, new + { + sceneName = activeScene.name, + totalIssues, + missingScripts, + brokenPrefabs, + repaired, + issues, + truncated = issues.Count > maxIssues || (totalIssues > issues.Count), + note = brokenPrefabs > 0 ? "Broken prefab references are not auto-repaired (too risky). Fix manually." : null + }); + } + private static object GetActiveSceneInfo() { try diff --git a/README.md b/README.md index cdbe6cd31..4dacbce97 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@
Recent Updates -* **v9.6.1 (beta)** — New `manage_build` tool: trigger player builds, switch platforms, configure player settings, manage build scenes and profiles (Unity 6+), run batch builds across multiple platforms, and async job tracking with polling. New `MaxPollSeconds` infrastructure for long-running tool operations. +* **v9.6.1 (beta)** — QoL extensions: `manage_editor` gains undo/redo actions. `manage_scene` gains multi-scene editing (additive load, close, set active, move GO between scenes), scene templates (3d_basic, 2d_basic, etc.), and scene validation with auto-repair. New `manage_build` tool: trigger player builds, switch platforms, configure player settings, manage build scenes and profiles (Unity 6+), run batch builds across multiple platforms, and async job tracking with polling. New `MaxPollSeconds` infrastructure for long-running tool operations. * **v9.5.4** — New `unity_reflect` and `unity_docs` tools for API verification: inspect live C# APIs via reflection and fetch official Unity documentation (ScriptReference, Manual, package docs). New `manage_packages` tool: install, remove, search, and manage Unity packages and scoped registries. Includes input validation, dependency checks on removal, and git URL warnings. * **v9.5.3** — New `manage_graphics` tool (33 actions): volume/post-processing, light baking, rendering stats, pipeline settings, URP renderer features. 3 new resources: `volumes`, `rendering_stats`, `renderer_features`. * **v9.5.2** — New `manage_camera` tool with Cinemachine support (presets, priority, noise, blending, extensions), `cameras` resource, priority persistence fix via SerializedProperty. diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index f55c02947..8b7746657 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -246,6 +246,38 @@ def restore(): print_success("Package restored from backup") +@editor.command("undo") +@handle_unity_errors +def undo(): + """Undo the last editor action. + + \b + Examples: + unity-mcp editor undo + """ + config = get_config() + result = run_command("manage_editor", {"action": "undo"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Undo performed") + + +@editor.command("redo") +@handle_unity_errors +def redo(): + """Redo the last undone action. + + \b + Examples: + unity-mcp editor redo + """ + config = get_config() + result = run_command("manage_editor", {"action": "redo"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Redo performed") + + @editor.command("menu") @click.argument("menu_path") @handle_unity_errors diff --git a/Server/src/cli/commands/scene.py b/Server/src/cli/commands/scene.py index ec4a4e58f..34f32385c 100644 --- a/Server/src/cli/commands/scene.py +++ b/Server/src/cli/commands/scene.py @@ -6,13 +6,13 @@ from typing import Optional, Any from cli.utils.config import get_config -from cli.utils.output import format_output, print_error, print_success +from cli.utils.output import format_output, print_error, print_success, print_warning from cli.utils.connection import run_command, handle_unity_errors @click.group() def scene(): - """Scene operations - hierarchy, load, save, create scenes.""" + """Scene operations - hierarchy, load, save, create, multi-scene, validation.""" pass @@ -163,14 +163,28 @@ def save(path: Optional[str]): default=None, help="Path to create the scene at." ) +@click.option( + "--template", "-t", + default=None, + type=click.Choice(["empty", "default", "3d_basic", "2d_basic"]), + help="Scene template (omit for empty scene)." +) @handle_unity_errors -def create(name: str, path: Optional[str]): - """Create a new scene. +def create(name: str, path: Optional[str], template: Optional[str]): + """Create a new scene, optionally from a template. + + \b + Templates: + empty - Empty scene, no default objects + default - Camera + Directional Light (Unity default) + 3d_basic - Default + ground plane + 2d_basic - Default + orthographic camera \b Examples: unity-mcp scene create "NewLevel" - unity-mcp scene create "TestScene" --path "Assets/Scenes/Test" + unity-mcp scene create "Level1" --template 3d_basic + unity-mcp scene create "Level1" --template 2d_basic --path "Assets/Scenes" """ config = get_config() @@ -180,11 +194,14 @@ def create(name: str, path: Optional[str]): } if path: params["path"] = path + if template: + params["template"] = template result = run_command("manage_scene", params, config) click.echo(format_output(result, config.format)) if result.get("success"): - print_success(f"Created scene: {name}") + label = f" from template '{template}'" if template else "" + print_success(f"Created scene{label}: {name}") @scene.command("build-settings") @@ -196,3 +213,140 @@ def build_settings(): click.echo(format_output(result, config.format)) +# ── Multi-scene editing ────────────────────────────────────────────── + + +@scene.command("loaded") +@handle_unity_errors +def loaded(): + """List all currently loaded scenes. + + \b + Examples: + unity-mcp scene loaded + """ + config = get_config() + result = run_command("manage_scene", {"action": "get_loaded_scenes"}, config) + click.echo(format_output(result, config.format)) + + +@scene.command("open-additive") +@click.argument("scene_path") +@handle_unity_errors +def open_additive(scene_path: str): + """Open a scene additively (keeps current scene loaded). + + \b + Examples: + unity-mcp scene open-additive "Assets/Scenes/Level2.unity" + """ + config = get_config() + result = run_command("manage_scene", { + "action": "load", + "path": scene_path, + "additive": True, + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Opened additively: {scene_path}") + + +@scene.command("close") +@click.argument("scene_name") +@click.option("--remove", is_flag=True, help="Fully remove scene instead of just unloading.") +@handle_unity_errors +def close(scene_name: str, remove: bool): + """Close/unload a loaded scene. + + \b + Examples: + unity-mcp scene close "Level2" + unity-mcp scene close "Level2" --remove + """ + config = get_config() + params: dict[str, Any] = { + "action": "close_scene", + "sceneName": scene_name, + } + if remove: + params["removeScene"] = True + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Closed scene: {scene_name}") + + +@scene.command("set-active") +@click.argument("scene_name") +@handle_unity_errors +def set_active(scene_name: str): + """Set a loaded scene as the active scene. + + \b + Examples: + unity-mcp scene set-active "Level2" + """ + config = get_config() + result = run_command("manage_scene", { + "action": "set_active_scene", + "sceneName": scene_name, + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set active: {scene_name}") + + +@scene.command("move-to") +@click.argument("target") +@click.argument("scene_name") +@handle_unity_errors +def move_to(target: str, scene_name: str): + """Move a root GameObject to another loaded scene. + + \b + Examples: + unity-mcp scene move-to "Player" "Level2" + """ + config = get_config() + result = run_command("manage_scene", { + "action": "move_to_scene", + "target": target, + "sceneName": scene_name, + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Moved '{target}' to scene '{scene_name}'") + + +# ── Scene validation ───────────────────────────────────────────────── + + +@scene.command("validate") +@click.option("--repair", is_flag=True, help="Auto-fix missing scripts (undoable).") +@handle_unity_errors +def validate(repair: bool): + """Validate the active scene for issues (missing scripts, broken prefabs). + + \b + Examples: + unity-mcp scene validate + unity-mcp scene validate --repair + """ + config = get_config() + params: dict[str, Any] = {"action": "validate"} + if repair: + params["autoRepair"] = True + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + data = result.get("data", {}) + total = data.get("totalIssues", 0) + repaired = data.get("repaired", 0) + if total == 0: + print_success("Scene is clean") + elif repaired > 0: + print_success(f"Found {total} issue(s), repaired {repaired}") + else: + print_warning(f"Found {total} issue(s), none repaired") + + diff --git a/Server/src/services/tools/manage_editor.py b/Server/src/services/tools/manage_editor.py index 8f44a0764..3ea4b7c11 100644 --- a/Server/src/services/tools/manage_editor.py +++ b/Server/src/services/tools/manage_editor.py @@ -10,14 +10,14 @@ from transport.legacy.unity_connection import async_send_command_with_retry @mcp_for_unity_tool( - description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, close_prefab_stage, deploy_package, restore_package. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup.", + description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, close_prefab_stage, deploy_package, restore_package, undo, redo. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name.", annotations=ToolAnnotations( title="Manage Editor", ), ) async def manage_editor( ctx: Context, - action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "close_prefab_stage", "deploy_package", "restore_package"], "Get and update the Unity Editor state. close_prefab_stage exits prefab editing mode and returns to the main scene stage. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup."], + action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "close_prefab_stage", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. close_prefab_stage exits prefab editing mode and returns to the main scene stage. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo."], tool_name: Annotated[str, "Tool name when setting active tool"] | None = None, tag_name: Annotated[str, diff --git a/Server/src/services/tools/manage_scene.py b/Server/src/services/tools/manage_scene.py index 8b94fc854..2861e632d 100644 --- a/Server/src/services/tools/manage_scene.py +++ b/Server/src/services/tools/manage_scene.py @@ -14,8 +14,10 @@ @mcp_for_unity_tool( description=( "Performs CRUD operations on Unity scenes. " - "Read-only actions: get_hierarchy, get_active, get_build_settings, scene_view_frame. " - "Modifying actions: create, load, save. " + "Read-only actions: get_hierarchy, get_active, get_build_settings, get_loaded_scenes, scene_view_frame. " + "Modifying actions: create (with optional template), load (with optional additive flag), save, " + "close_scene, set_active_scene, move_to_scene, validate (with optional auto_repair). " + "For build settings management (add/remove/enable scenes), use manage_build(action='scenes'). " "For screenshots, use manage_camera (screenshot, screenshot_multiview actions)." ), annotations=ToolAnnotations( @@ -33,6 +35,11 @@ async def manage_scene( "get_active", "get_build_settings", "scene_view_frame", + "close_scene", + "set_active_scene", + "get_loaded_scenes", + "move_to_scene", + "validate", ], "Perform CRUD operations on Unity scenes and control the Scene View camera."], name: Annotated[str, "Scene name."] | None = None, path: Annotated[str, "Scene path."] | None = None, @@ -56,6 +63,23 @@ async def manage_scene( "Child paging hint (safety)."] | None = None, include_transform: Annotated[bool | str, "If true, include local transform in node summaries."] | None = None, + # --- Multi-scene editing params --- + scene_name: Annotated[str, + "Scene name for multi-scene operations."] | None = None, + scene_path: Annotated[str, + "Full scene path (e.g. 'Assets/Scenes/Level2.unity')."] | None = None, + target: Annotated[str | int, + "GameObject reference (name, path, or instanceID) for move_to_scene."] | None = None, + remove_scene: Annotated[bool | str, + "For close_scene: true to fully remove, false to just unload."] | None = None, + additive: Annotated[bool | str, + "For load: true to open scene additively (keeps current scene)."] | None = None, + # --- Scene template --- + template: Annotated[str, + "For create: scene template ('empty', 'default', '3d_basic', '2d_basic'). Omit for empty scene."] | None = None, + # --- Scene validation --- + auto_repair: Annotated[bool | str, + "For validate: true to auto-fix missing scripts (undoable)."] | None = None, ) -> dict[str, Any]: unity_instance = await get_unity_instance_from_context(ctx) gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True) @@ -100,6 +124,28 @@ async def manage_scene( if coerced_include_transform is not None: params["includeTransform"] = coerced_include_transform + # Multi-scene editing params + if scene_name is not None: + params["sceneName"] = scene_name + if scene_path is not None: + params["scenePath"] = scene_path + if target is not None: + params["target"] = target + coerced_remove_scene = coerce_bool(remove_scene, default=None) + if coerced_remove_scene is not None: + params["removeScene"] = coerced_remove_scene + coerced_additive = coerce_bool(additive, default=None) + if coerced_additive is not None: + params["additive"] = coerced_additive + # Scene template + if template is not None: + params["template"] = template + + # Scene validation + coerced_auto_repair = coerce_bool(auto_repair, default=None) + if coerced_auto_repair is not None: + params["autoRepair"] = coerced_auto_repair + # Use centralized retry helper with instance routing response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_scene", params) diff --git a/Server/tests/test_manage_editor.py b/Server/tests/test_manage_editor.py new file mode 100644 index 000000000..837e41f8b --- /dev/null +++ b/Server/tests/test_manage_editor.py @@ -0,0 +1,92 @@ +"""Tests for manage_editor tool.""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from services.tools.manage_editor import manage_editor + +# ── Fixture ────────────────────────────────────────────────────────── + + +@pytest.fixture +def mock_unity(monkeypatch): + captured: dict[str, object] = {} + + async def fake_send(send_fn, unity_instance, tool_name, params): + captured["unity_instance"] = unity_instance + captured["tool_name"] = tool_name + captured["params"] = params + return {"success": True, "message": "ok"} + + monkeypatch.setattr( + "services.tools.manage_editor.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.manage_editor.send_with_unity_instance", + fake_send, + ) + return captured + + +# ── Undo/Redo ──────────────────────────────────────────────────────── + + +def test_undo_forwards_to_unity(mock_unity): + result = asyncio.run(manage_editor(SimpleNamespace(), action="undo")) + assert result["success"] is True + assert mock_unity["params"]["action"] == "undo" + assert mock_unity["tool_name"] == "manage_editor" + + +def test_redo_forwards_to_unity(mock_unity): + result = asyncio.run(manage_editor(SimpleNamespace(), action="redo")) + assert result["success"] is True + assert mock_unity["params"]["action"] == "redo" + + +# ── All Unity-forwarded actions ────────────────────────────────────── + +UNITY_FORWARDED_ACTIONS = [ + "play", "pause", "stop", "set_active_tool", + "add_tag", "remove_tag", "add_layer", "remove_layer", + "close_prefab_stage", "deploy_package", "restore_package", + "undo", "redo", +] + + +@pytest.mark.parametrize("action_name", UNITY_FORWARDED_ACTIONS) +def test_every_action_forwards_to_unity(mock_unity, action_name): + result = asyncio.run(manage_editor(SimpleNamespace(), action=action_name)) + assert result["success"] is True + assert mock_unity["params"]["action"] == action_name + + +# ── Python-only actions ────────────────────────────────────────────── + + +def test_telemetry_status_handled_python_side(mock_unity): + result = asyncio.run(manage_editor(SimpleNamespace(), action="telemetry_status")) + assert result["success"] is True + assert "telemetry_enabled" in result + assert "params" not in mock_unity + + +def test_telemetry_ping_handled_python_side(mock_unity): + result = asyncio.run(manage_editor(SimpleNamespace(), action="telemetry_ping")) + assert result["success"] is True + assert "params" not in mock_unity + + +# ── None params omitted ───────────────────────────────────────────── + + +def test_undo_omits_none_params(mock_unity): + result = asyncio.run(manage_editor(SimpleNamespace(), action="undo")) + assert result["success"] is True + params = mock_unity["params"] + assert "toolName" not in params + assert "tagName" not in params + assert "layerName" not in params diff --git a/Server/tests/test_manage_scene.py b/Server/tests/test_manage_scene.py new file mode 100644 index 000000000..3c0391c02 --- /dev/null +++ b/Server/tests/test_manage_scene.py @@ -0,0 +1,141 @@ +"""Tests for manage_scene tool — multi-scene, templates, validation.""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from services.tools.manage_scene import manage_scene + +# ── Fixture ────────────────────────────────────────────────────────── + + +@pytest.fixture +def mock_unity(monkeypatch): + captured: dict[str, object] = {} + + async def fake_send(send_fn, unity_instance, tool_name, params): + captured["unity_instance"] = unity_instance + captured["tool_name"] = tool_name + captured["params"] = params + return {"success": True, "message": "ok"} + + monkeypatch.setattr( + "services.tools.manage_scene.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.manage_scene.send_with_unity_instance", + fake_send, + ) + monkeypatch.setattr( + "services.tools.manage_scene.preflight", + AsyncMock(return_value=None), + ) + return captured + + +# ── All actions forward to Unity ───────────────────────────────────── + +ALL_ACTIONS = [ + "create", "load", "save", "get_hierarchy", + "get_active", "get_build_settings", "scene_view_frame", + "close_scene", "set_active_scene", "get_loaded_scenes", + "move_to_scene", + "validate", +] + + +@pytest.mark.parametrize("action_name", ALL_ACTIONS) +def test_every_action_forwards_to_unity(mock_unity, action_name): + result = asyncio.run(manage_scene(SimpleNamespace(), action=action_name)) + assert result["success"] is True + assert mock_unity["params"]["action"] == action_name + assert mock_unity["tool_name"] == "manage_scene" + + +# ── Multi-scene param passthrough ──────────────────────────────────── + + +def test_load_additive_passes_flag(mock_unity): + result = asyncio.run(manage_scene( + SimpleNamespace(), action="load", + path="Assets/Scenes/Level2.unity", additive=True, + )) + assert result["success"] is True + assert mock_unity["params"]["additive"] is True + + +def test_close_scene_passes_scene_name_and_remove(mock_unity): + result = asyncio.run(manage_scene( + SimpleNamespace(), action="close_scene", + scene_name="Level2", remove_scene=True, + )) + assert result["success"] is True + assert mock_unity["params"]["sceneName"] == "Level2" + assert mock_unity["params"]["removeScene"] is True + + +def test_move_to_scene_passes_target(mock_unity): + result = asyncio.run(manage_scene( + SimpleNamespace(), action="move_to_scene", + target="Player", scene_name="Level2", + )) + assert result["success"] is True + assert mock_unity["params"]["target"] == "Player" + assert mock_unity["params"]["sceneName"] == "Level2" + + +# ── Template param passthrough ─────────────────────────────────────── + + +def test_create_with_template_passes_param(mock_unity): + result = asyncio.run(manage_scene( + SimpleNamespace(), action="create", + name="TestScene", template="3d_basic", + )) + assert result["success"] is True + assert mock_unity["params"]["template"] == "3d_basic" + + +def test_create_without_template_omits_param(mock_unity): + result = asyncio.run(manage_scene( + SimpleNamespace(), action="create", + name="TestScene", + )) + assert result["success"] is True + assert "template" not in mock_unity["params"] + + +# ── Validation param passthrough ───────────────────────────────────── + + +def test_validate_forwards_to_unity(mock_unity): + result = asyncio.run(manage_scene(SimpleNamespace(), action="validate")) + assert result["success"] is True + assert mock_unity["params"]["action"] == "validate" + assert "autoRepair" not in mock_unity["params"] + + +def test_validate_with_auto_repair(mock_unity): + result = asyncio.run(manage_scene( + SimpleNamespace(), action="validate", auto_repair=True, + )) + assert result["success"] is True + assert mock_unity["params"]["autoRepair"] is True + + +# ── None params omitted ───────────────────────────────────────────── + + +def test_none_params_omitted(mock_unity): + result = asyncio.run(manage_scene(SimpleNamespace(), action="get_loaded_scenes")) + assert result["success"] is True + params = mock_unity["params"] + assert "sceneName" not in params + assert "scenePath" not in params + assert "target" not in params + assert "removeScene" not in params + assert "additive" not in params + assert "template" not in params + assert "autoRepair" not in params diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorUndoTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorUndoTests.cs new file mode 100644 index 000000000..b10ea5c5a --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorUndoTests.cs @@ -0,0 +1,50 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Tests.EditMode.Tools +{ + [TestFixture] + public class ManageEditorUndoTests + { + [Test] + public void Undo_ReturnsSuccess() + { + var p = new JObject { ["action"] = "undo" }; + var result = ManageEditor.HandleCommand(p); + var r = result as JObject ?? JObject.FromObject(result); + Assert.IsTrue(r.Value("success"), r.ToString()); + } + + [Test] + public void Redo_ReturnsSuccess() + { + var p = new JObject { ["action"] = "redo" }; + var result = ManageEditor.HandleCommand(p); + var r = result as JObject ?? JObject.FromObject(result); + Assert.IsTrue(r.Value("success"), r.ToString()); + } + + [Test] + public void Undo_AfterRecordedChange_RevertsChange() + { + var go = new GameObject("UndoTestGO"); + try + { + Undo.RecordObject(go, "Rename UndoTestGO"); + go.name = "RenamedGO"; + Undo.FlushUndoRecordObjects(); + + var p = new JObject { ["action"] = "undo" }; + ManageEditor.HandleCommand(p); + Assert.AreEqual("UndoTestGO", go.name, "Name should revert after undo"); + } + finally + { + Object.DestroyImmediate(go); + } + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneMultiSceneTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneMultiSceneTests.cs new file mode 100644 index 000000000..df1bc9af0 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneMultiSceneTests.cs @@ -0,0 +1,97 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Tests.EditMode.Tools +{ + [TestFixture] + public class ManageSceneMultiSceneTests + { + [Test] + public void GetLoadedScenes_ReturnsAtLeastOne() + { + var p = new JObject { ["action"] = "get_loaded_scenes" }; + var result = ManageScene.HandleCommand(p); + var r = result as JObject ?? JObject.FromObject(result); + Assert.IsTrue(r.Value("success"), r.ToString()); + var scenes = r["data"]?["scenes"] as JArray; + Assert.IsNotNull(scenes); + Assert.GreaterOrEqual(scenes.Count, 1); + } + + [Test] + public void CloseScene_LastScene_ReturnsError() + { + if (SceneManager.sceneCount > 1) + { + Assert.Ignore("Test requires a single scene; editor has additive scenes open."); + return; + } + var active = SceneManager.GetActiveScene(); + var p = new JObject + { + ["action"] = "close_scene", + ["sceneName"] = active.name + }; + var result = ManageScene.HandleCommand(p); + var r = result as JObject ?? JObject.FromObject(result); + Assert.IsFalse(r.Value("success"), "Should fail to close last scene"); + } + + [Test] + public void MoveToScene_MissingTarget_ReturnsError() + { + var p = new JObject + { + ["action"] = "move_to_scene", + ["sceneName"] = "SomeScene" + }; + var result = ManageScene.HandleCommand(p); + var r = result as JObject ?? JObject.FromObject(result); + Assert.IsFalse(r.Value("success")); + } + + [Test] + public void MoveToScene_NonExistentGO_ReturnsError() + { + var p = new JObject + { + ["action"] = "move_to_scene", + ["target"] = "NonExistentGO_99999", + ["sceneName"] = "SomeScene" + }; + var result = ManageScene.HandleCommand(p); + var r = result as JObject ?? JObject.FromObject(result); + Assert.IsFalse(r.Value("success")); + } + + [Test] + public void ModifyBuildSettings_RedirectsToManageBuild() + { + var p = new JObject + { + ["action"] = "modify_build_settings", + ["scenePath"] = "Assets/Scenes/Test.unity", + ["operation"] = "add" + }; + var result = ManageScene.HandleCommand(p); + var r = result as JObject ?? JObject.FromObject(result); + Assert.IsFalse(r.Value("success")); + Assert.IsTrue(r.Value("message").Contains("manage_build")); + } + + [Test] + public void SetActiveScene_NotFound_ReturnsError() + { + var p = new JObject + { + ["action"] = "set_active_scene", + ["sceneName"] = "NonExistentScene_99999" + }; + var result = ManageScene.HandleCommand(p); + var r = result as JObject ?? JObject.FromObject(result); + Assert.IsFalse(r.Value("success")); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneTemplateTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneTemplateTests.cs new file mode 100644 index 000000000..48b3dad78 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneTemplateTests.cs @@ -0,0 +1,25 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnity.Tests.EditMode.Tools +{ + [TestFixture] + public class ManageSceneTemplateTests + { + [Test] + public void Create_UnknownTemplate_ReturnsError() + { + var p = new JObject + { + ["action"] = "create", + ["name"] = "TemplateTest", + ["path"] = "Assets/Scenes", + ["template"] = "nonexistent_template" + }; + var result = ManageScene.HandleCommand(p); + var r = result as JObject ?? JObject.FromObject(result); + Assert.IsFalse(r.Value("success"), "Unknown template should fail"); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneValidationTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneValidationTests.cs new file mode 100644 index 000000000..76db80254 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneValidationTests.cs @@ -0,0 +1,32 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnity.Tests.EditMode.Tools +{ + [TestFixture] + public class ManageSceneValidationTests + { + [Test] + public void Validate_CleanScene_ReturnsNoIssues() + { + var p = new JObject { ["action"] = "validate" }; + var result = ManageScene.HandleCommand(p); + var r = result as JObject ?? JObject.FromObject(result); + Assert.IsTrue(r.Value("success"), r.ToString()); + var data = r["data"]; + Assert.IsNotNull(data); + Assert.AreEqual(0, data.Value("totalIssues")); + } + + [Test] + public void Validate_WithAutoRepair_CleanScene_RepairsNothing() + { + var p = new JObject { ["action"] = "validate", ["autoRepair"] = true }; + var result = ManageScene.HandleCommand(p); + var r = result as JObject ?? JObject.FromObject(result); + Assert.IsTrue(r.Value("success"), r.ToString()); + Assert.AreEqual(0, r["data"].Value("repaired")); + } + } +} diff --git a/docs/i18n/README-zh.md b/docs/i18n/README-zh.md index ef2643f58..9eb8c505b 100644 --- a/docs/i18n/README-zh.md +++ b/docs/i18n/README-zh.md @@ -20,7 +20,7 @@
最近更新 -* **v9.6.1 (beta)** — 新增 `manage_build` 工具:触发玩家构建、切换平台、配置玩家设置、管理构建场景和配置文件(Unity 6+)、跨多平台批量构建、异步任务跟踪与轮询。新增 `MaxPollSeconds` 基础设施,支持长时间运行的工具操作。 +* **v9.6.1 (beta)** — QoL 扩展:`manage_editor` 新增撤销/重做操作。`manage_scene` 新增多场景编辑(叠加加载、关闭、设置活动场景、跨场景移动物体)、场景模板(3d_basic、2d_basic 等)、场景验证与自动修复。新增 `manage_build` 工具:触发玩家构建、切换平台、配置玩家设置、管理构建场景和配置文件(Unity 6+)、跨多平台批量构建、异步任务跟踪与轮询。新增 `MaxPollSeconds` 基础设施,支持长时间运行的工具操作。 * **v9.5.4** — 新增 `unity_reflect` 和 `unity_docs` 工具用于 API 验证:通过反射检查实时 C# API,获取官方 Unity 文档(ScriptReference、Manual、包文档)。新增 `manage_packages` 工具:安装、移除、搜索和管理 Unity 包及作用域注册表。包含输入验证、移除时依赖检查和 git URL 警告。 * **v9.5.3** — 新增 `manage_graphics` 工具(33个操作):体积/后处理、光照烘焙、渲染统计、管线设置、URP渲染器特性。3个新资源:`volumes`、`rendering_stats`、`renderer_features`。 * **v9.5.2** — 新增 `manage_camera` 工具,支持 Cinemachine(预设、优先级、噪声、混合、扩展)、`cameras` 资源、通过 SerializedProperty 修复优先级持久化问题。 diff --git a/manifest.json b/manifest.json index 657cd96ad..7e7046724 100644 --- a/manifest.json +++ b/manifest.json @@ -91,7 +91,7 @@ }, { "name": "manage_editor", - "description": "Control Unity Editor state, play mode, and preferences" + "description": "Control Unity Editor state, play mode, preferences, undo/redo" }, { "name": "manage_gameobject", @@ -123,7 +123,7 @@ }, { "name": "manage_scene", - "description": "Load, save, query hierarchy, and manage Unity scenes" + "description": "Load, save, query hierarchy, multi-scene editing, templates, validation, and manage Unity scenes" }, { "name": "manage_script",