From 193e8f93f8639d88ed2906002266546db95cd572 Mon Sep 17 00:00:00 2001 From: fedtop Date: Wed, 25 Feb 2026 23:02:48 +0900 Subject: [PATCH 1/3] feat: add manage_profiler tool for reading frame data and controlling profiler state Adds a new built-in tool that reads Unity Profiler frame data directly via ProfilerDriver, providing the same data visible in the Profiler window. Supports reading multiple frames, filtering by thread/sample name/minimum time, and controlling profiler state (enable/disable/clear). Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/ManageProfiler.cs | 167 +++++++++++++++++++ Server/src/services/tools/manage_profiler.py | 81 +++++++++ 2 files changed, 248 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/ManageProfiler.cs create mode 100644 Server/src/services/tools/manage_profiler.py diff --git a/MCPForUnity/Editor/Tools/ManageProfiler.cs b/MCPForUnity/Editor/Tools/ManageProfiler.cs new file mode 100644 index 000000000..3b299df2a --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageProfiler.cs @@ -0,0 +1,167 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor.Profiling; +using UnityEditorInternal; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Reads Unity Profiler frame data and controls profiler state. + /// Uses ProfilerDriver to access the same data visible in the Profiler window. + /// + [McpForUnityTool("manage_profiler", AutoRegister = false)] + public static class ManageProfiler + { + /// + /// Main handler for profiler actions. + /// + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var p = new ToolParams(@params); + + var actionResult = p.GetRequired("action"); + if (!actionResult.IsSuccess) + { + return new ErrorResponse(actionResult.ErrorMessage); + } + string action = actionResult.Value.ToLowerInvariant(); + + switch (action) + { + case "read_frames": + return ReadFrames(p); + case "enable": + return EnableProfiler(); + case "disable": + return DisableProfiler(); + case "status": + return GetStatus(); + case "clear": + return ClearFrames(); + default: + return new ErrorResponse($"Unknown action: '{action}'. Valid actions: read_frames, enable, disable, status, clear."); + } + } + + private static object ReadFrames(ToolParams p) + { + if (ProfilerDriver.enabled == false) + { + return new ErrorResponse("Profiler is not enabled. Use action='enable' first or open the Profiler window."); + } + + int lastFrame = ProfilerDriver.lastFrameIndex; + int firstFrame = ProfilerDriver.firstFrameIndex; + if (lastFrame < 0 || firstFrame < 0) + { + return new ErrorResponse("No profiler frames available."); + } + + int frameCount = p.GetInt("frameCount", 1) ?? 1; + int threadIndex = p.GetInt("thread", 0) ?? 0; + string filter = p.Get("filter") ?? ""; + float minMs = p.GetFloat("minMs", 0.01f) ?? 0.01f; + + frameCount = System.Math.Min(frameCount, lastFrame - firstFrame + 1); + + var frames = new List(); + for (int f = 0; f < frameCount; f++) + { + int frameIndex = lastFrame - f; + using var frameData = ProfilerDriver.GetRawFrameDataView(frameIndex, threadIndex); + if (frameData == null || frameData.valid == false) + { + continue; + } + + var collected = new List<(string name, float ms, int childCount)>(); + for (int i = 0; i < frameData.sampleCount; i++) + { + float timeMs = frameData.GetSampleTimeMs(i); + if (timeMs < minMs) + { + continue; + } + + string name = frameData.GetSampleName(i); + if (string.IsNullOrEmpty(filter) == false && + name.IndexOf(filter, System.StringComparison.OrdinalIgnoreCase) < 0) + { + continue; + } + + collected.Add((name, timeMs, frameData.GetSampleChildrenCount(i))); + } + + // Sort by time descending + collected.Sort((a, b) => b.ms.CompareTo(a.ms)); + + var samples = new List(collected.Count); + foreach (var s in collected) + { + samples.Add(new { s.name, s.ms, formatted = FormatTime(s.ms), s.childCount }); + } + + frames.Add(new + { + frameIndex, + threadIndex, + sampleCount = frameData.sampleCount, + samples + }); + } + + return new SuccessResponse($"{frames.Count} frames read", new { frames }); + } + + private static object EnableProfiler() + { + UnityEngine.Profiling.Profiler.enabled = true; + ProfilerDriver.enabled = true; + return new SuccessResponse("Profiler enabled."); + } + + private static object DisableProfiler() + { + ProfilerDriver.enabled = false; + UnityEngine.Profiling.Profiler.enabled = false; + return new SuccessResponse("Profiler disabled."); + } + + private static object GetStatus() + { + int firstFrame = ProfilerDriver.firstFrameIndex; + int lastFrame = ProfilerDriver.lastFrameIndex; + int frameCount = lastFrame >= firstFrame ? lastFrame - firstFrame + 1 : 0; + + return new SuccessResponse("Profiler status", new + { + enabled = ProfilerDriver.enabled, + firstFrame, + lastFrame, + frameCount, + isPlaying = Application.isPlaying + }); + } + + private static object ClearFrames() + { + ProfilerDriver.ClearAllFrames(); + return new SuccessResponse("All profiler frames cleared."); + } + + private static string FormatTime(float ms) + { + if (ms >= 1f) return $"{ms:F2}ms"; + if (ms >= 0.001f) return $"{ms * 1000f:F2}us"; + return $"{ms * 1_000_000f:F0}ns"; + } + } +} diff --git a/Server/src/services/tools/manage_profiler.py b/Server/src/services/tools/manage_profiler.py new file mode 100644 index 000000000..4f46899ee --- /dev/null +++ b/Server/src/services/tools/manage_profiler.py @@ -0,0 +1,81 @@ +from typing import Annotated, Any, Literal + +from fastmcp import Context +from mcp.types import ToolAnnotations + +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 + + +@mcp_for_unity_tool( + description="Reads Unity Profiler frame data and controls profiler state. " + "Uses ProfilerDriver to access the same data visible in the Profiler window. " + "Read-only actions: read_frames, status. " + "Modifying actions: enable, disable, clear.", + annotations=ToolAnnotations( + title="Manage Profiler", + ), +) +async def manage_profiler( + ctx: Context, + action: Annotated[ + Literal["read_frames", "enable", "disable", "status", "clear"], + "Action to perform. " + "'read_frames' reads profiler frame data (same as Profiler window). " + "'enable' starts profiler recording. " + "'disable' stops profiler recording. " + "'status' returns current profiler state. " + "'clear' clears all recorded frames." + ], + frame_count: Annotated[ + int | str, + "Number of recent frames to read (default: 1). Only used with 'read_frames'." + ] | None = None, + thread: Annotated[ + int | str, + "Thread index to read (default: 0). " + "0=Main Thread (game logic, ECS, physics, animation), " + "1=Render Thread (GPU commands, shader), " + "2+=Job Worker Threads (Burst/Jobs parallel work). " + "Only used with 'read_frames'." + ] | None = None, + filter: Annotated[ + str, + "Filter samples by name (case-insensitive substring match). Only used with 'read_frames'." + ] | None = None, + min_ms: Annotated[ + float | str, + "Minimum milliseconds to include a sample (default: 0.01). Only used with 'read_frames'." + ] | None = None, +) -> dict[str, Any]: + unity_instance = get_unity_instance_from_context(ctx) + + try: + params: dict[str, Any] = {"action": action} + + if action == "read_frames": + if frame_count is not None: + params["frameCount"] = int(frame_count) + if thread is not None: + params["thread"] = int(thread) + if filter is not None: + params["filter"] = filter + if min_ms is not None: + params["minMs"] = float(min_ms) + + response = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "manage_profiler", params + ) + + if isinstance(response, dict) and response.get("success"): + return { + "success": True, + "message": response.get("message", "Profiler operation successful."), + "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"Python error managing profiler: {str(e)}"} From bfde901422460e33757721b334bad1f907a3a23e Mon Sep 17 00:00:00 2001 From: fedtop Date: Wed, 25 Feb 2026 23:08:57 +0900 Subject: [PATCH 2/3] feat: add reserialize_assets tool for force-reserializing Unity assets Forces Unity to reserialize assets to the current serialization format. Supports single path or array of paths. Useful after Unity upgrades, script changes affecting serialized data, or .meta regeneration. Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/ReserializeAssets.cs | 63 +++++++++++++++++++ .../src/services/tools/reserialize_assets.py | 57 +++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/ReserializeAssets.cs create mode 100644 Server/src/services/tools/reserialize_assets.py diff --git a/MCPForUnity/Editor/Tools/ReserializeAssets.cs b/MCPForUnity/Editor/Tools/ReserializeAssets.cs new file mode 100644 index 000000000..b4e75063d --- /dev/null +++ b/MCPForUnity/Editor/Tools/ReserializeAssets.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Forces Unity to reserialize assets, updating them to the current serialization format. + /// Useful after Unity version upgrades, script changes that affect serialized data, + /// or when assets need their .meta files regenerated. + /// + [McpForUnityTool("reserialize_assets", AutoRegister = false)] + public static class ReserializeAssets + { + /// + /// Main handler for asset reserialization. + /// + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var p = new ToolParams(@params); + + // Support both single path and array of paths + string singlePath = p.Get("path"); + JToken pathsToken = @params["paths"]; + + var paths = new List(); + + if (pathsToken != null && pathsToken.Type == JTokenType.Array) + { + foreach (var item in pathsToken) + { + string val = item.ToString(); + if (!string.IsNullOrEmpty(val)) + { + paths.Add(val); + } + } + } + else if (!string.IsNullOrEmpty(singlePath)) + { + paths.Add(singlePath); + } + + if (paths.Count == 0) + { + return new ErrorResponse("'path' (string) or 'paths' (array of strings) parameter required."); + } + + AssetDatabase.ForceReserializeAssets(paths); + + return new SuccessResponse( + $"Reserialized {paths.Count} asset(s).", + new { paths = paths.ToArray() } + ); + } + } +} diff --git a/Server/src/services/tools/reserialize_assets.py b/Server/src/services/tools/reserialize_assets.py new file mode 100644 index 000000000..83309612b --- /dev/null +++ b/Server/src/services/tools/reserialize_assets.py @@ -0,0 +1,57 @@ +from typing import Annotated, Any + +from fastmcp import Context +from mcp.types import ToolAnnotations + +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 + + +@mcp_for_unity_tool( + description="Forces Unity to reserialize assets, updating them to the current serialization format. " + "Useful after Unity version upgrades, script changes that affect serialized data, " + "or when assets need their .meta files regenerated. " + "Provide either 'path' for a single asset or 'paths' for multiple assets.", + annotations=ToolAnnotations( + title="Reserialize Assets", + ), +) +async def reserialize_assets( + ctx: Context, + path: Annotated[ + str, + "Single asset path to reserialize (e.g., 'Assets/Prefabs/Player.prefab')." + ] | None = None, + paths: Annotated[ + list[str], + "Array of asset paths to reserialize." + ] | None = None, +) -> dict[str, Any]: + unity_instance = get_unity_instance_from_context(ctx) + + try: + params: dict[str, Any] = {} + + if paths is not None: + params["paths"] = paths + elif path is not None: + params["path"] = path + else: + return {"success": False, "message": "'path' or 'paths' parameter required."} + + response = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "reserialize_assets", params + ) + + if isinstance(response, dict) and response.get("success"): + return { + "success": True, + "message": response.get("message", "Assets reserialized."), + "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"Python error reserializing assets: {str(e)}"} From a541994a9029bf407e0c8a3913c6b306ab5c052c Mon Sep 17 00:00:00 2001 From: fedtop Date: Wed, 25 Feb 2026 23:42:41 +0900 Subject: [PATCH 3/3] test: add test coverage for manage_profiler and reserialize_assets Python integration tests (14 tests): - manage_profiler: parameter mapping, string coercion, all 5 actions, error passthrough, non-read_frames params isolation - reserialize_assets: single/multiple paths, path priority, missing params validation, error passthrough C# EditMode tests: - ManageProfiler: null params, missing action, unknown action, case insensitivity, status response, enable/disable, clear, read_frames when disabled - ReserializeAssets: null params, missing path/paths, empty paths array Co-Authored-By: Claude Opus 4.6 --- .../tests/integration/test_manage_profiler.py | 238 ++++++++++++++++++ .../integration/test_reserialize_assets.py | 135 ++++++++++ .../EditMode/Tools/ManageProfilerTests.cs | 104 ++++++++ .../EditMode/Tools/ReserializeAssetsTests.cs | 46 ++++ 4 files changed, 523 insertions(+) create mode 100644 Server/tests/integration/test_manage_profiler.py create mode 100644 Server/tests/integration/test_reserialize_assets.py create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProfilerTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReserializeAssetsTests.cs diff --git a/Server/tests/integration/test_manage_profiler.py b/Server/tests/integration/test_manage_profiler.py new file mode 100644 index 000000000..afd3603c6 --- /dev/null +++ b/Server/tests/integration/test_manage_profiler.py @@ -0,0 +1,238 @@ +""" +Tests for the manage_profiler tool. + +Validates parameter mapping, action routing, and response handling. +""" +import pytest + +from .test_helpers import DummyContext +import services.tools.manage_profiler as profiler_mod + + +@pytest.mark.asyncio +async def test_manage_profiler_read_frames_default_params(monkeypatch): + """Test read_frames with default parameters sends correct command.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["cmd"] = cmd + captured["params"] = params + return { + "success": True, + "message": "1 frames read", + "data": {"frames": []}, + } + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="read_frames", + ) + + assert resp.get("success") is True + assert captured["cmd"] == "manage_profiler" + assert captured["params"]["action"] == "read_frames" + # No optional params should be sent when not provided + assert "frameCount" not in captured["params"] + assert "thread" not in captured["params"] + assert "filter" not in captured["params"] + assert "minMs" not in captured["params"] + + +@pytest.mark.asyncio +async def test_manage_profiler_read_frames_with_all_params(monkeypatch): + """Test read_frames passes all optional parameters correctly.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return { + "success": True, + "message": "5 frames read", + "data": {"frames": []}, + } + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="read_frames", + frame_count=5, + thread=1, + filter="Render", + min_ms=0.5, + ) + + assert resp.get("success") is True + p = captured["params"] + assert p["action"] == "read_frames" + assert p["frameCount"] == 5 + assert p["thread"] == 1 + assert p["filter"] == "Render" + assert p["minMs"] == 0.5 + + +@pytest.mark.asyncio +async def test_manage_profiler_read_frames_string_coercion(monkeypatch): + """Test that string values for numeric params are coerced to proper types.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "ok", "data": None} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="read_frames", + frame_count="3", + thread="2", + min_ms="0.01", + ) + + assert resp.get("success") is True + p = captured["params"] + assert p["frameCount"] == 3 + assert isinstance(p["frameCount"], int) + assert p["thread"] == 2 + assert isinstance(p["thread"], int) + assert p["minMs"] == 0.01 + assert isinstance(p["minMs"], float) + + +@pytest.mark.asyncio +async def test_manage_profiler_enable(monkeypatch): + """Test enable action sends correct command.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["cmd"] = cmd + captured["params"] = params + return {"success": True, "message": "Profiler enabled."} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="enable", + ) + + assert resp.get("success") is True + assert captured["cmd"] == "manage_profiler" + assert captured["params"]["action"] == "enable" + # No read_frames params should be present + assert "frameCount" not in captured["params"] + + +@pytest.mark.asyncio +async def test_manage_profiler_disable(monkeypatch): + """Test disable action sends correct command.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "Profiler disabled."} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="disable", + ) + + assert resp.get("success") is True + assert captured["params"]["action"] == "disable" + + +@pytest.mark.asyncio +async def test_manage_profiler_status(monkeypatch): + """Test status action returns profiler state data.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return { + "success": True, + "message": "Profiler status", + "data": { + "enabled": True, + "firstFrame": 0, + "lastFrame": 100, + "frameCount": 101, + }, + } + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="status", + ) + + assert resp.get("success") is True + assert resp["data"]["enabled"] is True + assert resp["data"]["frameCount"] == 101 + + +@pytest.mark.asyncio +async def test_manage_profiler_clear(monkeypatch): + """Test clear action sends correct command.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "All profiler frames cleared."} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="clear", + ) + + assert resp.get("success") is True + assert captured["params"]["action"] == "clear" + + +@pytest.mark.asyncio +async def test_manage_profiler_unity_error_passthrough(monkeypatch): + """Test that Unity-side errors are passed through correctly.""" + + async def fake_send(cmd, params, **kwargs): + return {"success": False, "error": "Profiler is not enabled."} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="read_frames", + ) + + assert resp.get("success") is False + + +@pytest.mark.asyncio +async def test_manage_profiler_non_read_frames_ignores_extra_params(monkeypatch): + """Test that non-read_frames actions don't send read_frames-specific params.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "ok"} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="enable", + frame_count=5, + thread=1, + filter="test", + min_ms=0.1, + ) + + assert resp.get("success") is True + p = captured["params"] + assert p == {"action": "enable"} diff --git a/Server/tests/integration/test_reserialize_assets.py b/Server/tests/integration/test_reserialize_assets.py new file mode 100644 index 000000000..e628824a1 --- /dev/null +++ b/Server/tests/integration/test_reserialize_assets.py @@ -0,0 +1,135 @@ +""" +Tests for the reserialize_assets tool. + +Validates parameter handling for single path, multiple paths, and error cases. +""" +import pytest + +from .test_helpers import DummyContext +import services.tools.reserialize_assets as reserialize_mod + + +@pytest.mark.asyncio +async def test_reserialize_assets_single_path(monkeypatch): + """Test reserialization with a single path parameter.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["cmd"] = cmd + captured["params"] = params + return { + "success": True, + "message": "Reserialized 1 asset(s).", + "data": {"paths": ["Assets/Prefabs/Player.prefab"]}, + } + + monkeypatch.setattr(reserialize_mod, "async_send_command_with_retry", fake_send) + + resp = await reserialize_mod.reserialize_assets( + ctx=DummyContext(), + path="Assets/Prefabs/Player.prefab", + ) + + assert resp.get("success") is True + assert captured["cmd"] == "reserialize_assets" + assert captured["params"]["path"] == "Assets/Prefabs/Player.prefab" + assert "paths" not in captured["params"] + + +@pytest.mark.asyncio +async def test_reserialize_assets_multiple_paths(monkeypatch): + """Test reserialization with an array of paths.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return { + "success": True, + "message": "Reserialized 3 asset(s).", + "data": { + "paths": [ + "Assets/Prefabs/A.prefab", + "Assets/Prefabs/B.prefab", + "Assets/Materials/C.mat", + ] + }, + } + + monkeypatch.setattr(reserialize_mod, "async_send_command_with_retry", fake_send) + + resp = await reserialize_mod.reserialize_assets( + ctx=DummyContext(), + paths=[ + "Assets/Prefabs/A.prefab", + "Assets/Prefabs/B.prefab", + "Assets/Materials/C.mat", + ], + ) + + assert resp.get("success") is True + assert captured["params"]["paths"] == [ + "Assets/Prefabs/A.prefab", + "Assets/Prefabs/B.prefab", + "Assets/Materials/C.mat", + ] + assert "path" not in captured["params"] + + +@pytest.mark.asyncio +async def test_reserialize_assets_paths_preferred_over_path(monkeypatch): + """Test that paths array takes priority when both path and paths are provided.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "ok", "data": None} + + monkeypatch.setattr(reserialize_mod, "async_send_command_with_retry", fake_send) + + resp = await reserialize_mod.reserialize_assets( + ctx=DummyContext(), + path="Assets/single.prefab", + paths=["Assets/multi1.prefab", "Assets/multi2.prefab"], + ) + + assert resp.get("success") is True + assert "paths" in captured["params"] + assert "path" not in captured["params"] + + +@pytest.mark.asyncio +async def test_reserialize_assets_no_params_returns_error(monkeypatch): + """Test that providing neither path nor paths returns an error without calling Unity.""" + send_called = False + + async def fake_send(cmd, params, **kwargs): + nonlocal send_called + send_called = True + return {"success": True} + + monkeypatch.setattr(reserialize_mod, "async_send_command_with_retry", fake_send) + + resp = await reserialize_mod.reserialize_assets( + ctx=DummyContext(), + ) + + assert resp.get("success") is False + assert "path" in resp.get("message", "").lower() + assert send_called is False, "Should not call Unity when no paths provided" + + +@pytest.mark.asyncio +async def test_reserialize_assets_unity_error_passthrough(monkeypatch): + """Test that Unity-side errors are passed through correctly.""" + + async def fake_send(cmd, params, **kwargs): + return {"success": False, "error": "Asset not found"} + + monkeypatch.setattr(reserialize_mod, "async_send_command_with_retry", fake_send) + + resp = await reserialize_mod.reserialize_assets( + ctx=DummyContext(), + path="Assets/NonExistent.prefab", + ) + + assert resp.get("success") is False diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProfilerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProfilerTests.cs new file mode 100644 index 000000000..03c4b5eaf --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProfilerTests.cs @@ -0,0 +1,104 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnityTests.Editor.Tools +{ + /// + /// EditMode tests for the ManageProfiler tool. + /// Tests parameter validation, action routing, and error handling. + /// + [TestFixture] + public class ManageProfilerTests + { + private static JObject ToJO(object o) => JObject.FromObject(o); + + [Test] + public void HandleCommand_NullParams_ReturnsError() + { + var result = ManageProfiler.HandleCommand(null); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.IsNotNull(jo["error"]); + Assert.That((string)jo["error"], Does.Contain("cannot be null")); + } + + [Test] + public void HandleCommand_MissingAction_ReturnsError() + { + var result = ManageProfiler.HandleCommand(new JObject()); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.IsNotNull(jo["error"]); + } + + [Test] + public void HandleCommand_UnknownAction_ReturnsError() + { + var result = ManageProfiler.HandleCommand(new JObject { ["action"] = "invalid_action" }); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.That((string)jo["error"], Does.Contain("Unknown action")); + } + + [Test] + public void HandleCommand_ActionCaseInsensitive() + { + // Both "STATUS" and "status" should be recognized + var upper = ManageProfiler.HandleCommand(new JObject { ["action"] = "STATUS" }); + var lower = ManageProfiler.HandleCommand(new JObject { ["action"] = "status" }); + var upperJo = ToJO(upper); + var lowerJo = ToJO(lower); + Assert.AreEqual((bool)upperJo["success"], (bool)lowerJo["success"]); + } + + [Test] + public void HandleCommand_Status_ReturnsProfilerState() + { + var result = ManageProfiler.HandleCommand(new JObject { ["action"] = "status" }); + var jo = ToJO(result); + Assert.IsTrue((bool)jo["success"]); + Assert.IsNotNull(jo["data"]); + + var data = jo["data"]; + Assert.IsNotNull(data["enabled"], "Should report enabled state"); + Assert.IsNotNull(data["firstFrame"], "Should report first frame index"); + Assert.IsNotNull(data["lastFrame"], "Should report last frame index"); + Assert.IsNotNull(data["frameCount"], "Should report frame count"); + } + + [Test] + public void HandleCommand_EnableDisable_Succeeds() + { + // Enable + var enableResult = ManageProfiler.HandleCommand(new JObject { ["action"] = "enable" }); + var enableJo = ToJO(enableResult); + Assert.IsTrue((bool)enableJo["success"]); + + // Disable + var disableResult = ManageProfiler.HandleCommand(new JObject { ["action"] = "disable" }); + var disableJo = ToJO(disableResult); + Assert.IsTrue((bool)disableJo["success"]); + } + + [Test] + public void HandleCommand_Clear_Succeeds() + { + var result = ManageProfiler.HandleCommand(new JObject { ["action"] = "clear" }); + var jo = ToJO(result); + Assert.IsTrue((bool)jo["success"]); + } + + [Test] + public void HandleCommand_ReadFrames_WhenProfilerDisabled_ReturnsError() + { + // Ensure profiler is disabled first + ManageProfiler.HandleCommand(new JObject { ["action"] = "disable" }); + + var result = ManageProfiler.HandleCommand(new JObject { ["action"] = "read_frames" }); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.That((string)jo["error"], Does.Contain("not enabled")); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReserializeAssetsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReserializeAssetsTests.cs new file mode 100644 index 000000000..9e78850b3 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReserializeAssetsTests.cs @@ -0,0 +1,46 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnityTests.Editor.Tools +{ + /// + /// EditMode tests for the ReserializeAssets tool. + /// Tests parameter validation and error handling. + /// + [TestFixture] + public class ReserializeAssetsTests + { + private static JObject ToJO(object o) => JObject.FromObject(o); + + [Test] + public void HandleCommand_NullParams_ReturnsError() + { + var result = ReserializeAssets.HandleCommand(null); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.IsNotNull(jo["error"]); + Assert.That((string)jo["error"], Does.Contain("cannot be null")); + } + + [Test] + public void HandleCommand_NoPathOrPaths_ReturnsError() + { + var result = ReserializeAssets.HandleCommand(new JObject()); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.That((string)jo["error"], Does.Contain("path")); + } + + [Test] + public void HandleCommand_EmptyPathsArray_ReturnsError() + { + var result = ReserializeAssets.HandleCommand(new JObject + { + ["paths"] = new JArray() + }); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + } + } +}