diff --git a/MCPForUnity/Editor/Tools/SearchMissingReferences.cs b/MCPForUnity/Editor/Tools/SearchMissingReferences.cs new file mode 100644 index 000000000..ccc6033cd --- /dev/null +++ b/MCPForUnity/Editor/Tools/SearchMissingReferences.cs @@ -0,0 +1,386 @@ +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Tools +{ + [McpForUnityTool("search_missing_references")] + public static class SearchMissingReferences + { + private const int MaxIssueDetails = 5000; + + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var p = new ToolParams(@params); + var pagination = PaginationRequest.FromParams(@params, defaultPageSize: 100); + pagination.PageSize = Mathf.Clamp(pagination.PageSize, 1, 500); + pagination.Cursor = Mathf.Max(0, pagination.Cursor); + + string scope = p.Get("scope", "scene"); + string pathFilter = p.Get("pathFilter"); + string componentFilter = p.Get("componentFilter"); + bool includeMissingScripts = p.GetBool("includeMissingScripts", true); + bool includeBrokenReferences = p.GetBool("includeBrokenReferences", true); + bool includeBrokenPrefabs = p.GetBool("includeBrokenPrefabs", true); + bool autoRepair = p.GetBool("autoRepair", false); + + try + { + var allIssues = new List(); + int missingScripts = 0; + int brokenReferences = 0; + int brokenPrefabs = 0; + int repaired = 0; + bool sceneDirty = false; + bool assetsDirty = false; + + if (scope == "scene") + { + var activeScene = SceneManager.GetActiveScene(); + if (!activeScene.IsValid() || !activeScene.isLoaded) + { + return new ErrorResponse("No active scene found."); + } + + foreach (var root in activeScene.GetRootGameObjects()) + { + foreach (var transform in root.GetComponentsInChildren(true)) + { + ScanGameObject( + transform.gameObject, + GetGameObjectPath(transform.gameObject), + includeMissingScripts, + includeBrokenReferences, + includeBrokenPrefabs, + componentFilter, + autoRepair, + allIssues, + ref missingScripts, + ref brokenReferences, + ref brokenPrefabs, + ref repaired, + ref sceneDirty, + ref assetsDirty, + isProjectAsset: false); + } + } + + if (sceneDirty) + { + EditorSceneManager.MarkSceneDirty(activeScene); + } + } + else if (scope == "project") + { + string[] searchInFolders = string.IsNullOrWhiteSpace(pathFilter) + ? null + : new[] { pathFilter }; + + var guids = AssetDatabase.FindAssets("t:Prefab t:ScriptableObject t:Material", searchInFolders) + .Distinct() + .ToArray(); + + foreach (var guid in guids) + { + string assetPath = AssetDatabase.GUIDToAssetPath(guid); + if (string.IsNullOrEmpty(assetPath)) + { + continue; + } + + var prefab = AssetDatabase.LoadAssetAtPath(assetPath); + if (prefab != null) + { + foreach (var transform in prefab.GetComponentsInChildren(true)) + { + ScanGameObject( + transform.gameObject, + assetPath + ":" + GetGameObjectPath(transform.gameObject), + includeMissingScripts, + includeBrokenReferences, + includeBrokenPrefabs, + componentFilter, + autoRepair, + allIssues, + ref missingScripts, + ref brokenReferences, + ref brokenPrefabs, + ref repaired, + ref sceneDirty, + ref assetsDirty, + isProjectAsset: true); + } + continue; + } + + var scriptableObject = AssetDatabase.LoadAssetAtPath(assetPath); + if (scriptableObject != null) + { + ScanSerializedObject( + scriptableObject, + assetPath, + scriptableObject.GetType().Name, + includeBrokenReferences, + componentFilter, + allIssues, + ref brokenReferences); + continue; + } + + var material = AssetDatabase.LoadAssetAtPath(assetPath); + if (material != null) + { + ScanSerializedObject( + material, + assetPath, + material.GetType().Name, + includeBrokenReferences, + componentFilter, + allIssues, + ref brokenReferences); + } + } + + if (assetsDirty) + { + AssetDatabase.SaveAssets(); + } + } + else + { + return new ErrorResponse("Invalid 'scope'. Expected 'scene' or 'project'."); + } + + int totalIssues = missingScripts + brokenReferences + brokenPrefabs; + int totalCount = allIssues.Count; + int cursor = Mathf.Clamp(pagination.Cursor, 0, totalCount); + var pagedIssues = allIssues.Skip(cursor).Take(pagination.PageSize).ToList(); + int endIndex = cursor + pagedIssues.Count; + int? nextCursor = endIndex < totalCount ? endIndex : (int?)null; + + string note = autoRepair + ? "Auto-repair removes missing scripts only. Broken references and broken prefab links are reported but not auto-repaired." + : "Broken references and broken prefab links are not auto-repaired."; + + return new SuccessResponse("Missing reference search completed.", new + { + scope, + totalIssues, + missingScripts, + brokenReferences, + brokenPrefabs, + repaired, + issues = pagedIssues, + pageSize = pagination.PageSize, + cursor, + nextCursor, + hasMore = nextCursor.HasValue, + totalCount, + truncated = totalIssues > allIssues.Count, + note + }); + } + catch (System.Exception ex) + { + McpLog.Error($"[SearchMissingReferences] Error searching missing references: {ex.Message}"); + return new ErrorResponse($"Error searching missing references: {ex.Message}"); + } + } + + private static void ScanGameObject( + GameObject go, + string path, + bool includeMissingScripts, + bool includeBrokenReferences, + bool includeBrokenPrefabs, + string componentFilter, + bool autoRepair, + List issues, + ref int missingScripts, + ref int brokenReferences, + ref int brokenPrefabs, + ref int repaired, + ref bool sceneDirty, + ref bool assetsDirty, + bool isProjectAsset) + { + if (go == null) + { + return; + } + + bool hasComponentFilter = !string.IsNullOrWhiteSpace(componentFilter); + + if (includeMissingScripts) + { + int missing = GameObjectUtility.GetMonoBehavioursWithMissingScriptCount(go); + if (missing > 0) + { + missingScripts += missing; + if (issues.Count < MaxIssueDetails) + { + issues.Add(new + { + type = "missing_script", + gameObject = go.name, + path, + component = "MissingMonoBehaviour", + property = "m_Script", + count = missing + }); + } + + if (autoRepair) + { + Undo.RegisterCompleteObjectUndo(go, "Remove Missing Scripts"); + int removed = GameObjectUtility.RemoveMonoBehavioursWithMissingScript(go); + if (removed > 0) + { + repaired += removed; + if (isProjectAsset) + { + EditorUtility.SetDirty(go); + assetsDirty = true; + } + else + { + sceneDirty = true; + } + } + } + } + } + + if (includeBrokenPrefabs) + { + var prefabStatus = PrefabUtility.GetPrefabInstanceStatus(go); + if (prefabStatus == PrefabInstanceStatus.MissingAsset) + { + brokenPrefabs++; + if (issues.Count < MaxIssueDetails) + { + issues.Add(new + { + type = "broken_prefab", + gameObject = go.name, + path, + component = "PrefabInstance", + property = "prefabAsset", + count = 1 + }); + } + } + } + + if (!includeBrokenReferences) + { + return; + } + + foreach (var component in go.GetComponents()) + { + if (component == null) + { + continue; + } + + string componentTypeName = component.GetType().Name; + if (hasComponentFilter && componentTypeName != componentFilter) + { + continue; + } + + ScanSerializedObject( + component, + path, + componentTypeName, + includeBrokenReferences, + componentFilter, + issues, + ref brokenReferences, + go.name); + } + } + + private static void ScanSerializedObject( + Object obj, + string path, + string componentTypeName, + bool includeBrokenReferences, + string componentFilter, + List issues, + ref int brokenReferences, + string gameObjectName = null) + { + if (!includeBrokenReferences || obj == null) + { + return; + } + + if (!string.IsNullOrWhiteSpace(componentFilter) && componentTypeName != componentFilter) + { + return; + } + + var serializedObject = new SerializedObject(obj); + var property = serializedObject.GetIterator(); + + while (property.NextVisible(true)) + { + if (property.propertyType != SerializedPropertyType.ObjectReference) + { + continue; + } + + if (property.objectReferenceValue != null || property.objectReferenceInstanceIDValue == 0) + { + continue; + } + + brokenReferences++; + if (issues.Count < MaxIssueDetails) + { + issues.Add(new + { + type = "broken_reference", + gameObject = gameObjectName ?? obj.name, + path, + component = componentTypeName, + property = property.propertyPath, + count = 1 + }); + } + } + } + + private static string GetGameObjectPath(GameObject go) + { + if (go == null) return string.Empty; + try + { + var names = new Stack(); + Transform t = go.transform; + while (t != null) + { + names.Push(t.name); + t = t.parent; + } + return string.Join("/", names); + } + catch + { + return go.name; + } + } + } +} diff --git a/Server/src/services/tools/search_missing_references.py b/Server/src/services/tools/search_missing_references.py new file mode 100644 index 000000000..f2ddd130e --- /dev/null +++ b/Server/src/services/tools/search_missing_references.py @@ -0,0 +1,144 @@ +""" +Tool for searching missing references and missing scripts in Unity scenes and assets. +""" +from typing import Annotated, Any, Literal + +from fastmcp import Context +from pydantic import Field +from services.registry import mcp_for_unity_tool +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry +from services.tools.utils import coerce_bool, coerce_int +from services.tools.preflight import preflight + + +@mcp_for_unity_tool( + description="Search for missing references, missing scripts, and broken prefab links in the active scene or across project assets. Returns detailed per-property issue reports with pagination. Use auto_repair to safely remove missing scripts with undo support." +) +async def search_missing_references( + ctx: Context, + scope: Annotated[ + Literal["scene", "project"], + Field( + default="scene", + description="Scan scope: the active scene hierarchy or project assets." + ), + ] = "scene", + include_missing_scripts: Annotated[ + bool | str, + Field( + default=True, + description="Check for MonoBehaviours with missing scripts (default: True)." + ), + ] = True, + include_broken_references: Annotated[ + bool | str, + Field( + default=True, + description="Check for broken object references in serialized properties (default: True)." + ), + ] = True, + include_broken_prefabs: Annotated[ + bool | str, + Field( + default=True, + description="Check for prefab instances with missing prefab assets (default: True)." + ), + ] = True, + path_filter: Annotated[ + str | None, + Field( + default=None, + description="Only scan assets under this path when scope='project'." + ), + ] = None, + component_filter: Annotated[ + str | None, + Field( + default=None, + description="Only report issues for this component type." + ), + ] = None, + auto_repair: Annotated[ + bool | str | None, + Field( + default=None, + description="Remove missing scripts with Undo support." + ), + ] = None, + page_size: Annotated[ + int | str | None, + Field( + default=None, + description="Number of issue results per page (default: 100, max: 500)." + ), + ] = None, + cursor: Annotated[ + int | str | None, + Field( + default=None, + description="Pagination cursor offset." + ), + ] = None, +) -> dict[str, Any]: + """ + Search for missing references, missing scripts, and broken prefab links. + + Scans the active scene hierarchy or project assets (prefabs, ScriptableObjects, + materials) for broken object references at the serialized property level. + + Returns paginated, per-property issue reports. Use auto_repair to safely + remove missing scripts with undo support. + """ + unity_instance = await get_unity_instance_from_context(ctx) + + coerced_include_missing_scripts = coerce_bool(include_missing_scripts, default=True) + coerced_include_broken_references = coerce_bool(include_broken_references, default=True) + coerced_include_broken_prefabs = coerce_bool(include_broken_prefabs, default=True) + coerced_auto_repair = coerce_bool(auto_repair, default=None) + coerced_page_size = coerce_int(page_size, default=100) + coerced_cursor = coerce_int(cursor, default=0) + + if coerced_page_size < 1: + coerced_page_size = 1 + if coerced_page_size > 500: + coerced_page_size = 500 + if coerced_cursor < 0: + coerced_cursor = 0 + + gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True) + if gate is not None: + return gate.model_dump() + + try: + params = { + "scope": scope, + "includeMissingScripts": coerced_include_missing_scripts, + "includeBrokenReferences": coerced_include_broken_references, + "includeBrokenPrefabs": coerced_include_broken_prefabs, + "pathFilter": path_filter if scope == "project" else None, + "componentFilter": component_filter, + "autoRepair": coerced_auto_repair, + "pageSize": coerced_page_size, + "cursor": coerced_cursor, + } + params = {k: v for k, v in params.items() if v is not None} + + response = await send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "search_missing_references", + params, + ) + + if isinstance(response, dict) and response.get("success"): + return { + "success": True, + "message": response.get("message", "Missing reference search completed."), + "data": response.get("data"), + } + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + return {"success": False, "message": f"Error searching missing references: {e!s}"} diff --git a/Server/tests/test_search_missing_references.py b/Server/tests/test_search_missing_references.py new file mode 100644 index 000000000..a51ecc1b3 --- /dev/null +++ b/Server/tests/test_search_missing_references.py @@ -0,0 +1,140 @@ +"""Tests for search_missing_references tool.""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from services.tools.search_missing_references import search_missing_references + + +@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.search_missing_references.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.search_missing_references.send_with_unity_instance", + fake_send, + ) + monkeypatch.setattr( + "services.tools.search_missing_references.preflight", + AsyncMock(return_value=None), + ) + return captured + + +def test_default_scope_is_scene(mock_unity): + result = asyncio.run(search_missing_references(SimpleNamespace())) + assert result["success"] is True + assert mock_unity["params"]["scope"] == "scene" + assert mock_unity["tool_name"] == "search_missing_references" + + +def test_project_scope_passes(mock_unity): + result = asyncio.run(search_missing_references(SimpleNamespace(), scope="project")) + assert result["success"] is True + assert mock_unity["params"]["scope"] == "project" + assert mock_unity["tool_name"] == "search_missing_references" + + +def test_path_filter_passes(mock_unity): + result = asyncio.run( + search_missing_references( + SimpleNamespace(), + scope="project", + path_filter="Assets/Prefabs", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["pathFilter"] == "Assets/Prefabs" + assert mock_unity["tool_name"] == "search_missing_references" + + +def test_component_filter_passes(mock_unity): + result = asyncio.run( + search_missing_references( + SimpleNamespace(), + component_filter="MeshRenderer", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["componentFilter"] == "MeshRenderer" + assert mock_unity["tool_name"] == "search_missing_references" + + +def test_auto_repair_passes(mock_unity): + result = asyncio.run( + search_missing_references( + SimpleNamespace(), + auto_repair=True, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["autoRepair"] is True + assert mock_unity["tool_name"] == "search_missing_references" + + +def test_auto_repair_default_omitted(mock_unity): + result = asyncio.run(search_missing_references(SimpleNamespace())) + assert result["success"] is True + assert "autoRepair" not in mock_unity["params"] + assert mock_unity["tool_name"] == "search_missing_references" + + +def test_include_flags_pass_through(mock_unity): + result = asyncio.run( + search_missing_references( + SimpleNamespace(), + include_missing_scripts=False, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["includeMissingScripts"] is False + assert mock_unity["tool_name"] == "search_missing_references" + + +def test_pagination_passes(mock_unity): + result = asyncio.run( + search_missing_references( + SimpleNamespace(), + page_size=50, + cursor=100, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["pageSize"] == 50 + assert mock_unity["params"]["cursor"] == 100 + assert mock_unity["tool_name"] == "search_missing_references" + + +def test_none_params_omitted(mock_unity): + result = asyncio.run(search_missing_references(SimpleNamespace())) + assert result["success"] is True + params = mock_unity["params"] + assert "pathFilter" not in params + assert "componentFilter" not in params + assert "autoRepair" not in params + assert mock_unity["tool_name"] == "search_missing_references" + + +def test_path_filter_omitted_for_scene_scope(mock_unity): + result = asyncio.run( + search_missing_references( + SimpleNamespace(), + scope="scene", + path_filter=None, + ) + ) + assert result["success"] is True + assert "pathFilter" not in mock_unity["params"] + assert mock_unity["tool_name"] == "search_missing_references"