From 0011a7a5eed069e84a53206408e90b37818aa44e Mon Sep 17 00:00:00 2001 From: zaferdace <47742545+zaferdace@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:14:22 +0000 Subject: [PATCH 01/18] feat: add manage_profiler tool for CPU timing, GC alloc, and animation profiling Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Editor/Tools/Profiler/ManageProfiler.cs | 49 ++++ .../Profiler/Operations/AnimationTimingOps.cs | 25 +++ .../Profiler/Operations/FrameTimingOps.cs | 36 +++ .../Tools/Profiler/Operations/GCAllocOps.cs | 33 +++ .../Profiler/Operations/PhysicsTimingOps.cs | 34 +++ .../Profiler/Operations/ScriptTimingOps.cs | 35 +++ Server/src/cli/commands/profiler.py | 54 +++++ Server/src/services/tools/manage_profiler.py | 57 +++++ Server/tests/test_manage_profiler.py | 209 ++++++++++++++++++ 9 files changed, 532 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/AnimationTimingOps.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/GCAllocOps.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs create mode 100644 Server/src/cli/commands/profiler.py create mode 100644 Server/src/services/tools/manage_profiler.py create mode 100644 Server/tests/test_manage_profiler.py diff --git a/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs b/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs new file mode 100644 index 000000000..38240e763 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs @@ -0,0 +1,49 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + [McpForUnityTool("manage_profiler", AutoRegister = false, Group = "core")] + public static class ManageProfiler + { + public static object HandleCommand(JObject @params) + { + if (@params == null) + return new ErrorResponse("Parameters cannot be null."); + + var p = new ToolParams(@params); + string action = p.Get("action")?.ToLowerInvariant(); + + if (string.IsNullOrEmpty(action)) + return new ErrorResponse("'action' parameter is required."); + + try + { + switch (action) + { + case "get_frame_timing": + return FrameTimingOps.GetFrameTiming(@params); + case "get_script_timing": + return ScriptTimingOps.GetScriptTiming(@params); + case "get_physics_timing": + return PhysicsTimingOps.GetPhysicsTiming(@params); + case "get_gc_alloc": + return GCAllocOps.GetGCAlloc(@params); + case "get_animation_timing": + return AnimationTimingOps.GetAnimationTiming(@params); + default: + return new ErrorResponse( + $"Unknown action: '{action}'. Valid actions: " + + "get_frame_timing, get_script_timing, get_physics_timing, " + + "get_gc_alloc, get_animation_timing."); + } + } + catch (Exception ex) + { + McpLog.Error($"[ManageProfiler] Action '{action}' failed: {ex}"); + return new ErrorResponse($"Error in action '{action}': {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/AnimationTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/AnimationTimingOps.cs new file mode 100644 index 000000000..b928cc1c6 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/AnimationTimingOps.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Unity.Profiling; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class AnimationTimingOps + { + internal static object GetAnimationTiming(JObject @params) + { + var data = new Dictionary(); + + using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Animation, "Animator.Update"); + data["animator_update_ms"] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; + data["animator_update_valid"] = recorder.Valid; + + return new + { + success = true, + message = "Animation timing captured.", + data + }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs new file mode 100644 index 000000000..2be41ca99 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Unity.Profiling; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class FrameTimingOps + { + private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new[] + { + ("Main Thread", "main_thread_ms"), + ("Render Thread", "render_thread_ms"), + ("CPU Frame Time", "cpu_frame_ms"), + ("GPU Frame Time", "gpu_frame_ms"), + }; + + internal static object GetFrameTiming(JObject @params) + { + var data = new Dictionary(); + + foreach (var (counterName, jsonKey) in COUNTER_MAP) + { + using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Internal, counterName); + data[jsonKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; + data[jsonKey.Replace("_ms", "_valid")] = recorder.Valid; + } + + return new + { + success = true, + message = "Frame timing captured.", + data + }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/GCAllocOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/GCAllocOps.cs new file mode 100644 index 000000000..12bf29471 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/GCAllocOps.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Unity.Profiling; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class GCAllocOps + { + internal static object GetGCAlloc(JObject @params) + { + var data = new Dictionary(); + + using (var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC.Alloc")) + { + data["gc_alloc_bytes"] = recorder.Valid ? recorder.CurrentValue : 0L; + data["gc_alloc_valid"] = recorder.Valid; + } + + using (var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC.Alloc.Count")) + { + data["gc_alloc_count"] = recorder.Valid ? recorder.CurrentValue : 0L; + data["gc_alloc_count_valid"] = recorder.Valid; + } + + return new + { + success = true, + message = "GC allocation stats captured.", + data + }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs new file mode 100644 index 000000000..f25bf7a13 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Unity.Profiling; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class PhysicsTimingOps + { + private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new[] + { + ("Physics.Processing", "processing_ms"), + ("Physics.FetchResults", "fetch_results_ms"), + }; + + internal static object GetPhysicsTiming(JObject @params) + { + var data = new Dictionary(); + + foreach (var (counterName, jsonKey) in COUNTER_MAP) + { + using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Physics, counterName); + data[jsonKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; + data[jsonKey.Replace("_ms", "_valid")] = recorder.Valid; + } + + return new + { + success = true, + message = "Physics timing captured.", + data + }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs new file mode 100644 index 000000000..2c893241f --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Unity.Profiling; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class ScriptTimingOps + { + private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new[] + { + ("Update.ScriptRunBehaviourUpdate", "update_ms"), + ("FixedBehaviourUpdate", "fixed_update_ms"), + ("PreLateUpdate.ScriptRunBehaviourLateUpdate", "late_update_ms"), + }; + + internal static object GetScriptTiming(JObject @params) + { + var data = new Dictionary(); + + foreach (var (counterName, jsonKey) in COUNTER_MAP) + { + using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Scripts, counterName); + data[jsonKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; + data[jsonKey.Replace("_ms", "_valid")] = recorder.Valid; + } + + return new + { + success = true, + message = "Script timing captured.", + data + }; + } + } +} diff --git a/Server/src/cli/commands/profiler.py b/Server/src/cli/commands/profiler.py new file mode 100644 index 000000000..f8fe579aa --- /dev/null +++ b/Server/src/cli/commands/profiler.py @@ -0,0 +1,54 @@ +import click +from cli.utils.connection import handle_unity_errors, run_command, get_config +from cli.utils.output import format_output + + +@click.group("profiler") +def profiler(): + """Read Unity Profiler counters: CPU timing, GC allocation, and animation.""" + pass + + +@profiler.command("frame-timing") +@handle_unity_errors +def frame_timing(): + """Get main thread, render thread, CPU and GPU frame timing (ms).""" + config = get_config() + result = run_command("manage_profiler", {"action": "get_frame_timing"}, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("script-timing") +@handle_unity_errors +def script_timing(): + """Get Update, FixedUpdate, and LateUpdate script execution time (ms).""" + config = get_config() + result = run_command("manage_profiler", {"action": "get_script_timing"}, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("physics-timing") +@handle_unity_errors +def physics_timing(): + """Get Physics.Processing and Physics.FetchResults time (ms).""" + config = get_config() + result = run_command("manage_profiler", {"action": "get_physics_timing"}, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("gc-alloc") +@handle_unity_errors +def gc_alloc(): + """Get GC allocation bytes and count per frame.""" + config = get_config() + result = run_command("manage_profiler", {"action": "get_gc_alloc"}, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("animation-timing") +@handle_unity_errors +def animation_timing(): + """Get Animator.Update time (ms).""" + config = get_config() + result = run_command("manage_profiler", {"action": "get_animation_timing"}, config) + click.echo(format_output(result, config.format)) diff --git a/Server/src/services/tools/manage_profiler.py b/Server/src/services/tools/manage_profiler.py new file mode 100644 index 000000000..350e0b71c --- /dev/null +++ b/Server/src/services/tools/manage_profiler.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 + +PROFILER_ACTIONS = [ + "get_frame_timing", + "get_script_timing", + "get_physics_timing", + "get_gc_alloc", + "get_animation_timing", +] + + +@mcp_for_unity_tool( + group="core", + description=( + "Read Unity Profiler counters for CPU timing, GC allocation, and animation.\n\n" + "FRAME TIMING:\n" + "- get_frame_timing: Main thread, render thread, CPU frame time, GPU frame time (ms)\n\n" + "SCRIPT TIMING:\n" + "- get_script_timing: Update, FixedUpdate, LateUpdate script execution time (ms)\n\n" + "PHYSICS TIMING:\n" + "- get_physics_timing: Physics.Processing, Physics.FetchResults time (ms)\n\n" + "GC ALLOCATION:\n" + "- get_gc_alloc: GC allocation bytes and count per frame\n\n" + "ANIMATION TIMING:\n" + "- get_animation_timing: Animator.Update time (ms)" + ), + annotations=ToolAnnotations( + title="Manage Profiler", + destructiveHint=False, + readOnlyHint=True, + ), +) +async def manage_profiler( + ctx: Context, + action: Annotated[str, "The profiler action to perform."], +) -> dict[str, Any]: + action_lower = action.lower() + if action_lower not in PROFILER_ACTIONS: + return { + "success": False, + "message": f"Unknown action '{action}'. Valid actions: {', '.join(PROFILER_ACTIONS)}", + } + + unity_instance = await get_unity_instance_from_context(ctx) + + result = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "manage_profiler", {"action": action_lower} + ) + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/Server/tests/test_manage_profiler.py b/Server/tests/test_manage_profiler.py new file mode 100644 index 000000000..ddd33d865 --- /dev/null +++ b/Server/tests/test_manage_profiler.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from services.tools.manage_profiler import ( + manage_profiler, + PROFILER_ACTIONS, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_unity(monkeypatch): + """Patch Unity transport layer and return captured call dict.""" + 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_profiler.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.manage_profiler.send_with_unity_instance", + fake_send, + ) + return captured + + +# --------------------------------------------------------------------------- +# Action list completeness +# --------------------------------------------------------------------------- + +def test_profiler_actions_count(): + assert len(PROFILER_ACTIONS) == 5 + + +def test_no_duplicate_actions(): + assert len(PROFILER_ACTIONS) == len(set(PROFILER_ACTIONS)) + + +def test_expected_actions_present(): + expected = { + "get_frame_timing", + "get_script_timing", + "get_physics_timing", + "get_gc_alloc", + "get_animation_timing", + } + assert set(PROFILER_ACTIONS) == expected + + +# --------------------------------------------------------------------------- +# Invalid / missing action +# --------------------------------------------------------------------------- + +def test_unknown_action_returns_error(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="nonexistent_action") + ) + assert result["success"] is False + assert "Unknown action" in result["message"] + assert "tool_name" not in mock_unity + + +def test_empty_action_returns_error(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="") + ) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# Each action forwards correctly +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("action_name", PROFILER_ACTIONS) +def test_every_action_forwards_to_unity(mock_unity, action_name): + """Every valid action should be forwarded to Unity without error.""" + result = asyncio.run( + manage_profiler(SimpleNamespace(), action=action_name) + ) + assert result["success"] is True + assert mock_unity["tool_name"] == "manage_profiler" + assert mock_unity["params"]["action"] == action_name + + +def test_get_frame_timing_sends_correct_params(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_frame_timing") + ) + assert result["success"] is True + assert mock_unity["tool_name"] == "manage_profiler" + assert mock_unity["params"] == {"action": "get_frame_timing"} + + +def test_get_script_timing_sends_correct_params(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_script_timing") + ) + assert result["success"] is True + assert mock_unity["params"] == {"action": "get_script_timing"} + + +def test_get_physics_timing_sends_correct_params(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_physics_timing") + ) + assert result["success"] is True + assert mock_unity["params"] == {"action": "get_physics_timing"} + + +def test_get_gc_alloc_sends_correct_params(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_gc_alloc") + ) + assert result["success"] is True + assert mock_unity["params"] == {"action": "get_gc_alloc"} + + +def test_get_animation_timing_sends_correct_params(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_animation_timing") + ) + assert result["success"] is True + assert mock_unity["params"] == {"action": "get_animation_timing"} + + +# --------------------------------------------------------------------------- +# Case insensitivity +# --------------------------------------------------------------------------- + +def test_action_case_insensitive(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="Get_Frame_Timing") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "get_frame_timing" + + +def test_action_uppercase(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="GET_GC_ALLOC") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "get_gc_alloc" + + +# --------------------------------------------------------------------------- +# Non-dict response wrapped +# --------------------------------------------------------------------------- + +def test_non_dict_response_wrapped(monkeypatch): + """When Unity returns a non-dict, it should be wrapped.""" + monkeypatch.setattr( + "services.tools.manage_profiler.get_unity_instance_from_context", + AsyncMock(return_value="unity-1"), + ) + + async def fake_send(send_fn, unity_instance, tool_name, params): + return "unexpected string response" + + monkeypatch.setattr( + "services.tools.manage_profiler.send_with_unity_instance", + fake_send, + ) + + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_frame_timing") + ) + assert result["success"] is False + assert "unexpected string response" in result["message"] + + +# --------------------------------------------------------------------------- +# Only action param is sent (no extra keys) +# --------------------------------------------------------------------------- + +def test_only_action_in_params(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_animation_timing") + ) + assert result["success"] is True + assert mock_unity["params"] == {"action": "get_animation_timing"} + + +# --------------------------------------------------------------------------- +# Tool registration +# --------------------------------------------------------------------------- + +def test_tool_registered_with_core_group(): + from services.registry.tool_registry import _tool_registry + + profiler_tools = [ + t for t in _tool_registry if t.get("name") == "manage_profiler" + ] + assert len(profiler_tools) == 1 + assert profiler_tools[0]["group"] == "core" From 92b4116c76317fe53b3ea46aafdf6e94a13900ba Mon Sep 17 00:00:00 2001 From: zaferdace <47742545+zaferdace@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:28:29 +0000 Subject: [PATCH 02/18] fix: correct ProfilerRecorder counter names in ScriptTiming and PhysicsTiming --- .../Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs | 4 ++-- .../Editor/Tools/Profiler/Operations/ScriptTimingOps.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs index f25bf7a13..1361f5fe5 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs @@ -8,8 +8,8 @@ internal static class PhysicsTimingOps { private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new[] { - ("Physics.Processing", "processing_ms"), - ("Physics.FetchResults", "fetch_results_ms"), + ("Physics.Simulate", "simulate_ms"), + ("Physics2D.Simulate", "simulate_2d_ms"), }; internal static object GetPhysicsTiming(JObject @params) diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs index 2c893241f..2996f9ee8 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs @@ -8,9 +8,9 @@ internal static class ScriptTimingOps { private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new[] { - ("Update.ScriptRunBehaviourUpdate", "update_ms"), + ("BehaviourUpdate", "update_ms"), ("FixedBehaviourUpdate", "fixed_update_ms"), - ("PreLateUpdate.ScriptRunBehaviourLateUpdate", "late_update_ms"), + ("LateBehaviourUpdate", "late_update_ms"), }; internal static object GetScriptTiming(JObject @params) From c0a2a2d0aa7be5aefad281fd6a9042eab58339a8 Mon Sep 17 00:00:00 2001 From: zaferdace <47742545+zaferdace@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:30:50 +0000 Subject: [PATCH 03/18] fix: update physics counter names in manage_profiler description --- Server/src/services/tools/manage_profiler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/src/services/tools/manage_profiler.py b/Server/src/services/tools/manage_profiler.py index 350e0b71c..c43060808 100644 --- a/Server/src/services/tools/manage_profiler.py +++ b/Server/src/services/tools/manage_profiler.py @@ -26,7 +26,7 @@ "SCRIPT TIMING:\n" "- get_script_timing: Update, FixedUpdate, LateUpdate script execution time (ms)\n\n" "PHYSICS TIMING:\n" - "- get_physics_timing: Physics.Processing, Physics.FetchResults time (ms)\n\n" + "- get_physics_timing: Physics.Simulate, Physics2D.Simulate time (ms)\n\n" "GC ALLOCATION:\n" "- get_gc_alloc: GC allocation bytes and count per frame\n\n" "ANIMATION TIMING:\n" From 42b020b44282c02218c0e08d127017227648669e Mon Sep 17 00:00:00 2001 From: zaferdace <47742545+zaferdace@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:26:20 +0000 Subject: [PATCH 04/18] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20correct=20counter=20names,=20CLI=20help=20text,=20s?= =?UTF-8?q?trengthen=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tools/Profiler/Operations/ScriptTimingOps.cs | 2 +- Server/src/cli/commands/profiler.py | 2 +- Server/tests/test_manage_profiler.py | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs index 2996f9ee8..06acaadd4 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs @@ -10,7 +10,7 @@ private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new { ("BehaviourUpdate", "update_ms"), ("FixedBehaviourUpdate", "fixed_update_ms"), - ("LateBehaviourUpdate", "late_update_ms"), + ("PreLateUpdate.ScriptRunBehaviourLateUpdate", "late_update_ms"), }; internal static object GetScriptTiming(JObject @params) diff --git a/Server/src/cli/commands/profiler.py b/Server/src/cli/commands/profiler.py index f8fe579aa..6e94da1e4 100644 --- a/Server/src/cli/commands/profiler.py +++ b/Server/src/cli/commands/profiler.py @@ -30,7 +30,7 @@ def script_timing(): @profiler.command("physics-timing") @handle_unity_errors def physics_timing(): - """Get Physics.Processing and Physics.FetchResults time (ms).""" + """Get Physics.Simulate and Physics2D.Simulate time (ms).""" config = get_config() result = run_command("manage_profiler", {"action": "get_physics_timing"}, config) click.echo(format_output(result, config.format)) diff --git a/Server/tests/test_manage_profiler.py b/Server/tests/test_manage_profiler.py index ddd33d865..802a10fb3 100644 --- a/Server/tests/test_manage_profiler.py +++ b/Server/tests/test_manage_profiler.py @@ -79,6 +79,8 @@ def test_empty_action_returns_error(mock_unity): manage_profiler(SimpleNamespace(), action="") ) assert result["success"] is False + assert "Unknown action" in result["message"] + assert "tool_name" not in mock_unity # --------------------------------------------------------------------------- @@ -96,6 +98,14 @@ def test_every_action_forwards_to_unity(mock_unity, action_name): assert mock_unity["params"]["action"] == action_name +def test_uses_unity_instance_from_context(mock_unity): + """manage_profiler should forward the context-derived Unity instance.""" + asyncio.run( + manage_profiler(SimpleNamespace(), action="get_frame_timing") + ) + assert mock_unity["unity_instance"] == "unity-instance-1" + + def test_get_frame_timing_sends_correct_params(mock_unity): result = asyncio.run( manage_profiler(SimpleNamespace(), action="get_frame_timing") From 5dcecd32cae7c2ebcf3c6aefcfbcac6a131ab4c0 Mon Sep 17 00:00:00 2001 From: zaferdace <47742545+zaferdace@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:39:05 +0000 Subject: [PATCH 05/18] refactor: use explicit valid keys in COUNTER_MAP tuples --- .../Tools/Profiler/Operations/FrameTimingOps.cs | 16 ++++++++-------- .../Profiler/Operations/PhysicsTimingOps.cs | 12 ++++++------ .../Tools/Profiler/Operations/ScriptTimingOps.cs | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs index 2be41ca99..f104e5670 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs @@ -6,23 +6,23 @@ namespace MCPForUnity.Editor.Tools.Profiler { internal static class FrameTimingOps { - private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new[] + private static readonly (string counterName, string valueKey, string validKey)[] COUNTER_MAP = new[] { - ("Main Thread", "main_thread_ms"), - ("Render Thread", "render_thread_ms"), - ("CPU Frame Time", "cpu_frame_ms"), - ("GPU Frame Time", "gpu_frame_ms"), + ("Main Thread", "main_thread_ms", "main_thread_valid"), + ("Render Thread", "render_thread_ms", "render_thread_valid"), + ("CPU Frame Time", "cpu_frame_ms", "cpu_frame_valid"), + ("GPU Frame Time", "gpu_frame_ms", "gpu_frame_valid"), }; internal static object GetFrameTiming(JObject @params) { var data = new Dictionary(); - foreach (var (counterName, jsonKey) in COUNTER_MAP) + foreach (var (counterName, valueKey, validKey) in COUNTER_MAP) { using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Internal, counterName); - data[jsonKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; - data[jsonKey.Replace("_ms", "_valid")] = recorder.Valid; + data[valueKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; + data[validKey] = recorder.Valid; } return new diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs index 1361f5fe5..555840e31 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs @@ -6,21 +6,21 @@ namespace MCPForUnity.Editor.Tools.Profiler { internal static class PhysicsTimingOps { - private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new[] + private static readonly (string counterName, string valueKey, string validKey)[] COUNTER_MAP = new[] { - ("Physics.Simulate", "simulate_ms"), - ("Physics2D.Simulate", "simulate_2d_ms"), + ("Physics.Simulate", "simulate_ms", "simulate_valid"), + ("Physics2D.Simulate", "simulate_2d_ms", "simulate_2d_valid"), }; internal static object GetPhysicsTiming(JObject @params) { var data = new Dictionary(); - foreach (var (counterName, jsonKey) in COUNTER_MAP) + foreach (var (counterName, valueKey, validKey) in COUNTER_MAP) { using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Physics, counterName); - data[jsonKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; - data[jsonKey.Replace("_ms", "_valid")] = recorder.Valid; + data[valueKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; + data[validKey] = recorder.Valid; } return new diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs index 06acaadd4..94b48dd1c 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs @@ -6,22 +6,22 @@ namespace MCPForUnity.Editor.Tools.Profiler { internal static class ScriptTimingOps { - private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new[] + private static readonly (string counterName, string valueKey, string validKey)[] COUNTER_MAP = new[] { - ("BehaviourUpdate", "update_ms"), - ("FixedBehaviourUpdate", "fixed_update_ms"), - ("PreLateUpdate.ScriptRunBehaviourLateUpdate", "late_update_ms"), + ("BehaviourUpdate", "update_ms", "update_valid"), + ("FixedBehaviourUpdate", "fixed_update_ms", "fixed_update_valid"), + ("PreLateUpdate.ScriptRunBehaviourLateUpdate", "late_update_ms", "late_update_valid"), }; internal static object GetScriptTiming(JObject @params) { var data = new Dictionary(); - foreach (var (counterName, jsonKey) in COUNTER_MAP) + foreach (var (counterName, valueKey, validKey) in COUNTER_MAP) { using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Scripts, counterName); - data[jsonKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; - data[jsonKey.Replace("_ms", "_valid")] = recorder.Valid; + data[valueKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; + data[validKey] = recorder.Valid; } return new From 771fdf1a0244f6666ba6d9af412cb04ed7bf04da Mon Sep 17 00:00:00 2001 From: zaferdace <47742545+zaferdace@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:48:40 +0000 Subject: [PATCH 06/18] fix: use official Unity Frame Timing API counter names --- .../Editor/Tools/Profiler/Operations/FrameTimingOps.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs index f104e5670..45083f96e 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs @@ -8,9 +8,9 @@ internal static class FrameTimingOps { private static readonly (string counterName, string valueKey, string validKey)[] COUNTER_MAP = new[] { - ("Main Thread", "main_thread_ms", "main_thread_valid"), - ("Render Thread", "render_thread_ms", "render_thread_valid"), - ("CPU Frame Time", "cpu_frame_ms", "cpu_frame_valid"), + ("CPU Main Thread Frame Time", "main_thread_ms", "main_thread_valid"), + ("CPU Render Thread Frame Time", "render_thread_ms", "render_thread_valid"), + ("CPU Total Frame Time", "cpu_frame_ms", "cpu_frame_valid"), ("GPU Frame Time", "gpu_frame_ms", "gpu_frame_valid"), }; From f4e98d8a4fa86e764d68f54fb90bd5e7892ba94b Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:55:49 -0400 Subject: [PATCH 07/18] feat(profiler): rewrite Python MCP tool with 14 actions across 4 groups Replaces the old 5-action read-only profiler with a comprehensive tool covering session control, generic counter reads, memory snapshots, and Frame Debugger. Adds "profiling" as a new opt-in tool group. --- Server/src/services/registry/tool_registry.py | 1 + Server/src/services/tools/manage_profiler.py | 95 +++++++--- Server/tests/test_manage_profiler.py | 173 +++++++++++++----- 3 files changed, 198 insertions(+), 71 deletions(-) diff --git a/Server/src/services/registry/tool_registry.py b/Server/src/services/registry/tool_registry.py index cfe147cde..069b44ab9 100644 --- a/Server/src/services/registry/tool_registry.py +++ b/Server/src/services/registry/tool_registry.py @@ -24,6 +24,7 @@ "scripting_ext": "ScriptableObject management", "testing": "Test runner & async test jobs", "probuilder": "ProBuilder 3D modeling – requires com.unity.probuilder package", + "profiling": "Unity Profiler session control, counters, memory snapshots & Frame Debugger", } DEFAULT_ENABLED_GROUPS: set[str] = {"core"} diff --git a/Server/src/services/tools/manage_profiler.py b/Server/src/services/tools/manage_profiler.py index c43060808..321f90309 100644 --- a/Server/src/services/tools/manage_profiler.py +++ b/Server/src/services/tools/manage_profiler.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any +from typing import Annotated, Any, Optional from fastmcp import Context from mcp.types import ToolAnnotations @@ -8,50 +8,99 @@ from transport.unity_transport import send_with_unity_instance from transport.legacy.unity_connection import async_send_command_with_retry -PROFILER_ACTIONS = [ - "get_frame_timing", - "get_script_timing", - "get_physics_timing", - "get_gc_alloc", - "get_animation_timing", +SESSION_ACTIONS = [ + "profiler_start", "profiler_stop", "profiler_status", "profiler_set_areas", ] +COUNTER_ACTIONS = [ + "get_frame_timing", "get_counters", "get_object_memory", +] + +MEMORY_SNAPSHOT_ACTIONS = [ + "memory_take_snapshot", "memory_list_snapshots", "memory_compare_snapshots", +] + +FRAME_DEBUGGER_ACTIONS = [ + "frame_debugger_enable", "frame_debugger_disable", "frame_debugger_get_events", +] + +UTILITY_ACTIONS = ["ping"] + +ALL_ACTIONS = ( + UTILITY_ACTIONS + SESSION_ACTIONS + COUNTER_ACTIONS + + MEMORY_SNAPSHOT_ACTIONS + FRAME_DEBUGGER_ACTIONS +) + @mcp_for_unity_tool( - group="core", + group="profiling", description=( - "Read Unity Profiler counters for CPU timing, GC allocation, and animation.\n\n" - "FRAME TIMING:\n" - "- get_frame_timing: Main thread, render thread, CPU frame time, GPU frame time (ms)\n\n" - "SCRIPT TIMING:\n" - "- get_script_timing: Update, FixedUpdate, LateUpdate script execution time (ms)\n\n" - "PHYSICS TIMING:\n" - "- get_physics_timing: Physics.Simulate, Physics2D.Simulate time (ms)\n\n" - "GC ALLOCATION:\n" - "- get_gc_alloc: GC allocation bytes and count per frame\n\n" - "ANIMATION TIMING:\n" - "- get_animation_timing: Animator.Update time (ms)" + "Unity Profiler session control, counter reads, memory snapshots, and Frame Debugger.\n\n" + "SESSION:\n" + "- profiler_start: Enable profiler, optionally record to .raw file (log_file, enable_callstacks)\n" + "- profiler_stop: Disable profiler, stop recording\n" + "- profiler_status: Get enabled state, active areas, recording path\n" + "- profiler_set_areas: Toggle ProfilerAreas on/off (areas dict)\n\n" + "COUNTERS:\n" + "- get_frame_timing: FrameTimingManager data (12 fields, synchronous)\n" + "- get_counters: Generic counter read by category + optional counter names (async, 1-frame wait)\n" + "- get_object_memory: Memory size of a specific object by path\n\n" + "MEMORY SNAPSHOT (requires com.unity.memoryprofiler):\n" + "- memory_take_snapshot: Capture memory snapshot to file\n" + "- memory_list_snapshots: List available .snap files\n" + "- memory_compare_snapshots: Compare two snapshot files\n\n" + "FRAME DEBUGGER:\n" + "- frame_debugger_enable: Turn on Frame Debugger, report event count\n" + "- frame_debugger_disable: Turn off Frame Debugger\n" + "- frame_debugger_get_events: Get draw call events (paged, best-effort via reflection)" ), annotations=ToolAnnotations( title="Manage Profiler", destructiveHint=False, - readOnlyHint=True, + readOnlyHint=False, ), ) async def manage_profiler( ctx: Context, action: Annotated[str, "The profiler action to perform."], + category: Annotated[Optional[str], "Profiler category name for get_counters (e.g. Render, Scripts, Memory, Physics)."] = None, + counters: Annotated[Optional[list[str]], "Specific counter names for get_counters. Omit to read all in category."] = None, + object_path: Annotated[Optional[str], "Scene hierarchy or asset path for get_object_memory."] = None, + log_file: Annotated[Optional[str], "Path to .raw file for profiler_start recording."] = None, + enable_callstacks: Annotated[Optional[bool], "Enable allocation callstacks for profiler_start."] = None, + areas: Annotated[Optional[dict[str, bool]], "Dict of area name to bool for profiler_set_areas."] = None, + snapshot_path: Annotated[Optional[str], "Output path for memory_take_snapshot."] = None, + search_path: Annotated[Optional[str], "Search directory for memory_list_snapshots."] = None, + snapshot_a: Annotated[Optional[str], "First snapshot path for memory_compare_snapshots."] = None, + snapshot_b: Annotated[Optional[str], "Second snapshot path for memory_compare_snapshots."] = None, + page_size: Annotated[Optional[int], "Page size for frame_debugger_get_events (default 50)."] = None, + cursor: Annotated[Optional[int], "Cursor offset for frame_debugger_get_events."] = None, ) -> dict[str, Any]: action_lower = action.lower() - if action_lower not in PROFILER_ACTIONS: + if action_lower not in ALL_ACTIONS: return { "success": False, - "message": f"Unknown action '{action}'. Valid actions: {', '.join(PROFILER_ACTIONS)}", + "message": f"Unknown action '{action}'. Valid actions: {', '.join(ALL_ACTIONS)}", } unity_instance = await get_unity_instance_from_context(ctx) + params_dict: dict[str, Any] = {"action": action_lower} + + param_map = { + "category": category, "counters": counters, + "object_path": object_path, + "log_file": log_file, "enable_callstacks": enable_callstacks, + "areas": areas, + "snapshot_path": snapshot_path, "search_path": search_path, + "snapshot_a": snapshot_a, "snapshot_b": snapshot_b, + "page_size": page_size, "cursor": cursor, + } + for key, val in param_map.items(): + if val is not None: + params_dict[key] = val + result = await send_with_unity_instance( - async_send_command_with_retry, unity_instance, "manage_profiler", {"action": action_lower} + async_send_command_with_retry, unity_instance, "manage_profiler", params_dict ) return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/Server/tests/test_manage_profiler.py b/Server/tests/test_manage_profiler.py index 802a10fb3..fcb652d62 100644 --- a/Server/tests/test_manage_profiler.py +++ b/Server/tests/test_manage_profiler.py @@ -8,7 +8,12 @@ from services.tools.manage_profiler import ( manage_profiler, - PROFILER_ACTIONS, + ALL_ACTIONS, + SESSION_ACTIONS, + COUNTER_ACTIONS, + MEMORY_SNAPSHOT_ACTIONS, + FRAME_DEBUGGER_ACTIONS, + UTILITY_ACTIONS, ) @@ -43,22 +48,40 @@ async def fake_send(send_fn, unity_instance, tool_name, params): # --------------------------------------------------------------------------- def test_profiler_actions_count(): - assert len(PROFILER_ACTIONS) == 5 + assert len(ALL_ACTIONS) == 14 def test_no_duplicate_actions(): - assert len(PROFILER_ACTIONS) == len(set(PROFILER_ACTIONS)) + assert len(ALL_ACTIONS) == len(set(ALL_ACTIONS)) -def test_expected_actions_present(): - expected = { - "get_frame_timing", - "get_script_timing", - "get_physics_timing", - "get_gc_alloc", - "get_animation_timing", - } - assert set(PROFILER_ACTIONS) == expected +def test_session_actions(): + expected = {"profiler_start", "profiler_stop", "profiler_status", "profiler_set_areas"} + assert set(SESSION_ACTIONS) == expected + + +def test_counter_actions(): + expected = {"get_frame_timing", "get_counters", "get_object_memory"} + assert set(COUNTER_ACTIONS) == expected + + +def test_memory_snapshot_actions(): + expected = {"memory_take_snapshot", "memory_list_snapshots", "memory_compare_snapshots"} + assert set(MEMORY_SNAPSHOT_ACTIONS) == expected + + +def test_frame_debugger_actions(): + expected = {"frame_debugger_enable", "frame_debugger_disable", "frame_debugger_get_events"} + assert set(FRAME_DEBUGGER_ACTIONS) == expected + + +def test_utility_actions(): + assert UTILITY_ACTIONS == ["ping"] + + +def test_all_actions_is_union(): + expected = set(UTILITY_ACTIONS + SESSION_ACTIONS + COUNTER_ACTIONS + MEMORY_SNAPSHOT_ACTIONS + FRAME_DEBUGGER_ACTIONS) + assert set(ALL_ACTIONS) == expected # --------------------------------------------------------------------------- @@ -87,9 +110,14 @@ def test_empty_action_returns_error(mock_unity): # Each action forwards correctly # --------------------------------------------------------------------------- -@pytest.mark.parametrize("action_name", PROFILER_ACTIONS) +@pytest.mark.parametrize("action_name", [ + "ping", + "profiler_start", "profiler_stop", "profiler_status", "profiler_set_areas", + "get_frame_timing", "get_counters", "get_object_memory", + "memory_take_snapshot", "memory_list_snapshots", "memory_compare_snapshots", + "frame_debugger_enable", "frame_debugger_disable", "frame_debugger_get_events", +]) def test_every_action_forwards_to_unity(mock_unity, action_name): - """Every valid action should be forwarded to Unity without error.""" result = asyncio.run( manage_profiler(SimpleNamespace(), action=action_name) ) @@ -99,52 +127,114 @@ def test_every_action_forwards_to_unity(mock_unity, action_name): def test_uses_unity_instance_from_context(mock_unity): - """manage_profiler should forward the context-derived Unity instance.""" asyncio.run( manage_profiler(SimpleNamespace(), action="get_frame_timing") ) assert mock_unity["unity_instance"] == "unity-instance-1" -def test_get_frame_timing_sends_correct_params(mock_unity): +# --------------------------------------------------------------------------- +# Param forwarding +# --------------------------------------------------------------------------- + +def test_get_counters_forwards_category(mock_unity): result = asyncio.run( - manage_profiler(SimpleNamespace(), action="get_frame_timing") + manage_profiler(SimpleNamespace(), action="get_counters", category="Render") ) assert result["success"] is True - assert mock_unity["tool_name"] == "manage_profiler" - assert mock_unity["params"] == {"action": "get_frame_timing"} + assert mock_unity["params"]["category"] == "Render" -def test_get_script_timing_sends_correct_params(mock_unity): +def test_get_counters_forwards_counter_names(mock_unity): result = asyncio.run( - manage_profiler(SimpleNamespace(), action="get_script_timing") + manage_profiler( + SimpleNamespace(), action="get_counters", + category="Render", counters=["Draw Calls Count", "Batches Count"], + ) ) assert result["success"] is True - assert mock_unity["params"] == {"action": "get_script_timing"} + assert mock_unity["params"]["counters"] == ["Draw Calls Count", "Batches Count"] -def test_get_physics_timing_sends_correct_params(mock_unity): +def test_get_counters_omits_none_counters(mock_unity): result = asyncio.run( - manage_profiler(SimpleNamespace(), action="get_physics_timing") + manage_profiler(SimpleNamespace(), action="get_counters", category="Memory") ) assert result["success"] is True - assert mock_unity["params"] == {"action": "get_physics_timing"} + assert "counters" not in mock_unity["params"] -def test_get_gc_alloc_sends_correct_params(mock_unity): +def test_profiler_start_forwards_log_file(mock_unity): result = asyncio.run( - manage_profiler(SimpleNamespace(), action="get_gc_alloc") + manage_profiler(SimpleNamespace(), action="profiler_start", log_file="/tmp/profile.raw") ) assert result["success"] is True - assert mock_unity["params"] == {"action": "get_gc_alloc"} + assert mock_unity["params"]["log_file"] == "/tmp/profile.raw" -def test_get_animation_timing_sends_correct_params(mock_unity): +def test_profiler_start_forwards_callstacks(mock_unity): result = asyncio.run( - manage_profiler(SimpleNamespace(), action="get_animation_timing") + manage_profiler(SimpleNamespace(), action="profiler_start", enable_callstacks=True) ) assert result["success"] is True - assert mock_unity["params"] == {"action": "get_animation_timing"} + assert mock_unity["params"]["enable_callstacks"] is True + + +def test_profiler_set_areas_forwards_areas(mock_unity): + areas = {"CPU": True, "Audio": False} + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="profiler_set_areas", areas=areas) + ) + assert result["success"] is True + assert mock_unity["params"]["areas"] == areas + + +def test_get_object_memory_forwards_path(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_object_memory", object_path="/Player/Mesh") + ) + assert result["success"] is True + assert mock_unity["params"]["object_path"] == "/Player/Mesh" + + +def test_memory_take_snapshot_forwards_path(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="memory_take_snapshot", snapshot_path="/tmp/snap.snap") + ) + assert result["success"] is True + assert mock_unity["params"]["snapshot_path"] == "/tmp/snap.snap" + + +def test_memory_compare_forwards_both_paths(mock_unity): + result = asyncio.run( + manage_profiler( + SimpleNamespace(), action="memory_compare_snapshots", + snapshot_a="/tmp/a.snap", snapshot_b="/tmp/b.snap", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["snapshot_a"] == "/tmp/a.snap" + assert mock_unity["params"]["snapshot_b"] == "/tmp/b.snap" + + +def test_frame_debugger_get_events_forwards_paging(mock_unity): + result = asyncio.run( + manage_profiler( + SimpleNamespace(), action="frame_debugger_get_events", + page_size=25, cursor=50, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["page_size"] == 25 + assert mock_unity["params"]["cursor"] == 50 + + +def test_action_only_params_no_extras(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="profiler_stop") + ) + assert result["success"] is True + assert mock_unity["params"] == {"action": "profiler_stop"} # --------------------------------------------------------------------------- @@ -161,10 +251,10 @@ def test_action_case_insensitive(mock_unity): def test_action_uppercase(mock_unity): result = asyncio.run( - manage_profiler(SimpleNamespace(), action="GET_GC_ALLOC") + manage_profiler(SimpleNamespace(), action="PROFILER_STATUS") ) assert result["success"] is True - assert mock_unity["params"]["action"] == "get_gc_alloc" + assert mock_unity["params"]["action"] == "profiler_status" # --------------------------------------------------------------------------- @@ -172,7 +262,6 @@ def test_action_uppercase(mock_unity): # --------------------------------------------------------------------------- def test_non_dict_response_wrapped(monkeypatch): - """When Unity returns a non-dict, it should be wrapped.""" monkeypatch.setattr( "services.tools.manage_profiler.get_unity_instance_from_context", AsyncMock(return_value="unity-1"), @@ -193,27 +282,15 @@ async def fake_send(send_fn, unity_instance, tool_name, params): assert "unexpected string response" in result["message"] -# --------------------------------------------------------------------------- -# Only action param is sent (no extra keys) -# --------------------------------------------------------------------------- - -def test_only_action_in_params(mock_unity): - result = asyncio.run( - manage_profiler(SimpleNamespace(), action="get_animation_timing") - ) - assert result["success"] is True - assert mock_unity["params"] == {"action": "get_animation_timing"} - - # --------------------------------------------------------------------------- # Tool registration # --------------------------------------------------------------------------- -def test_tool_registered_with_core_group(): +def test_tool_registered_with_profiling_group(): from services.registry.tool_registry import _tool_registry profiler_tools = [ t for t in _tool_registry if t.get("name") == "manage_profiler" ] assert len(profiler_tools) == 1 - assert profiler_tools[0]["group"] == "core" + assert profiler_tools[0]["group"] == "profiling" From 7c2167108ef8fc2f8353359ce3792f2a1d00be07 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:24:30 -0400 Subject: [PATCH 08/18] feat(profiler): async dispatcher + SessionOps, delete contributor Ops files --- .../Editor/Tools/Profiler/ManageProfiler.cs | 60 +++++++++--- .../Profiler/Operations/AnimationTimingOps.cs | 25 ----- .../Tools/Profiler/Operations/GCAllocOps.cs | 33 ------- .../Profiler/Operations/PhysicsTimingOps.cs | 34 ------- .../Profiler/Operations/ScriptTimingOps.cs | 35 ------- .../Tools/Profiler/Operations/SessionOps.cs | 95 +++++++++++++++++++ 6 files changed, 143 insertions(+), 139 deletions(-) delete mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/AnimationTimingOps.cs delete mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/GCAllocOps.cs delete mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs delete mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs diff --git a/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs b/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs index 38240e763..606b53abd 100644 --- a/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs +++ b/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs @@ -1,13 +1,14 @@ using System; +using System.Threading.Tasks; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; namespace MCPForUnity.Editor.Tools.Profiler { - [McpForUnityTool("manage_profiler", AutoRegister = false, Group = "core")] + [McpForUnityTool("manage_profiler", AutoRegister = false, Group = "profiling")] public static class ManageProfiler { - public static object HandleCommand(JObject @params) + public static async Task HandleCommand(JObject @params) { if (@params == null) return new ErrorResponse("Parameters cannot be null."); @@ -22,21 +23,56 @@ public static object HandleCommand(JObject @params) { switch (action) { + // Session + case "profiler_start": + return SessionOps.Start(@params); + case "profiler_stop": + return SessionOps.Stop(@params); + case "profiler_status": + return SessionOps.Status(@params); + case "profiler_set_areas": + return SessionOps.SetAreas(@params); + + // Counters case "get_frame_timing": return FrameTimingOps.GetFrameTiming(@params); - case "get_script_timing": - return ScriptTimingOps.GetScriptTiming(@params); - case "get_physics_timing": - return PhysicsTimingOps.GetPhysicsTiming(@params); - case "get_gc_alloc": - return GCAllocOps.GetGCAlloc(@params); - case "get_animation_timing": - return AnimationTimingOps.GetAnimationTiming(@params); + case "get_counters": + return await CounterOps.GetCountersAsync(@params); + case "get_object_memory": + return ObjectMemoryOps.GetObjectMemory(@params); + + // Memory Snapshot + case "memory_take_snapshot": + return await MemorySnapshotOps.TakeSnapshotAsync(@params); + case "memory_list_snapshots": + return MemorySnapshotOps.ListSnapshots(@params); + case "memory_compare_snapshots": + return MemorySnapshotOps.CompareSnapshots(@params); + + // Frame Debugger + case "frame_debugger_enable": + return FrameDebuggerOps.Enable(@params); + case "frame_debugger_disable": + return FrameDebuggerOps.Disable(@params); + case "frame_debugger_get_events": + return FrameDebuggerOps.GetEvents(@params); + + // Utility + case "ping": + return new SuccessResponse("manage_profiler is available.", new + { + tool = "manage_profiler", + group = "profiling" + }); + default: return new ErrorResponse( $"Unknown action: '{action}'. Valid actions: " - + "get_frame_timing, get_script_timing, get_physics_timing, " - + "get_gc_alloc, get_animation_timing."); + + "profiler_start, profiler_stop, profiler_status, profiler_set_areas, " + + "get_frame_timing, get_counters, get_object_memory, " + + "memory_take_snapshot, memory_list_snapshots, memory_compare_snapshots, " + + "frame_debugger_enable, frame_debugger_disable, frame_debugger_get_events, " + + "ping."); } } catch (Exception ex) diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/AnimationTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/AnimationTimingOps.cs deleted file mode 100644 index b928cc1c6..000000000 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/AnimationTimingOps.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using Unity.Profiling; - -namespace MCPForUnity.Editor.Tools.Profiler -{ - internal static class AnimationTimingOps - { - internal static object GetAnimationTiming(JObject @params) - { - var data = new Dictionary(); - - using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Animation, "Animator.Update"); - data["animator_update_ms"] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; - data["animator_update_valid"] = recorder.Valid; - - return new - { - success = true, - message = "Animation timing captured.", - data - }; - } - } -} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/GCAllocOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/GCAllocOps.cs deleted file mode 100644 index 12bf29471..000000000 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/GCAllocOps.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using Unity.Profiling; - -namespace MCPForUnity.Editor.Tools.Profiler -{ - internal static class GCAllocOps - { - internal static object GetGCAlloc(JObject @params) - { - var data = new Dictionary(); - - using (var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC.Alloc")) - { - data["gc_alloc_bytes"] = recorder.Valid ? recorder.CurrentValue : 0L; - data["gc_alloc_valid"] = recorder.Valid; - } - - using (var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC.Alloc.Count")) - { - data["gc_alloc_count"] = recorder.Valid ? recorder.CurrentValue : 0L; - data["gc_alloc_count_valid"] = recorder.Valid; - } - - return new - { - success = true, - message = "GC allocation stats captured.", - data - }; - } - } -} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs deleted file mode 100644 index 555840e31..000000000 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using Unity.Profiling; - -namespace MCPForUnity.Editor.Tools.Profiler -{ - internal static class PhysicsTimingOps - { - private static readonly (string counterName, string valueKey, string validKey)[] COUNTER_MAP = new[] - { - ("Physics.Simulate", "simulate_ms", "simulate_valid"), - ("Physics2D.Simulate", "simulate_2d_ms", "simulate_2d_valid"), - }; - - internal static object GetPhysicsTiming(JObject @params) - { - var data = new Dictionary(); - - foreach (var (counterName, valueKey, validKey) in COUNTER_MAP) - { - using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Physics, counterName); - data[valueKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; - data[validKey] = recorder.Valid; - } - - return new - { - success = true, - message = "Physics timing captured.", - data - }; - } - } -} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs deleted file mode 100644 index 94b48dd1c..000000000 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using Unity.Profiling; - -namespace MCPForUnity.Editor.Tools.Profiler -{ - internal static class ScriptTimingOps - { - private static readonly (string counterName, string valueKey, string validKey)[] COUNTER_MAP = new[] - { - ("BehaviourUpdate", "update_ms", "update_valid"), - ("FixedBehaviourUpdate", "fixed_update_ms", "fixed_update_valid"), - ("PreLateUpdate.ScriptRunBehaviourLateUpdate", "late_update_ms", "late_update_valid"), - }; - - internal static object GetScriptTiming(JObject @params) - { - var data = new Dictionary(); - - foreach (var (counterName, valueKey, validKey) in COUNTER_MAP) - { - using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Scripts, counterName); - data[valueKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; - data[validKey] = recorder.Valid; - } - - return new - { - success = true, - message = "Script timing captured.", - data - }; - } - } -} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs new file mode 100644 index 000000000..f489c525f --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine.Profiling; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class SessionOps + { + private static readonly string[] AreaNames = Enum.GetNames(typeof(ProfilerArea)); + + internal static object Start(JObject @params) + { + var p = new ToolParams(@params); + string logFile = p.Get("log_file"); + bool enableCallstacks = p.GetBool("enable_callstacks"); + + Profiler.enabled = true; + + bool recording = false; + if (!string.IsNullOrEmpty(logFile)) + { + Profiler.logFile = logFile; + Profiler.enableBinaryLog = true; + recording = true; + } + + if (enableCallstacks) + Profiler.enableAllocationCallstacks = true; + + return new SuccessResponse("Profiler started.", new + { + enabled = Profiler.enabled, + recording, + log_file = recording ? Profiler.logFile : null, + allocation_callstacks = enableCallstacks, + }); + } + + internal static object Stop(JObject @params) + { + string previousLogFile = Profiler.enableBinaryLog ? Profiler.logFile : null; + + Profiler.enableBinaryLog = false; + Profiler.enableAllocationCallstacks = false; + Profiler.enabled = false; + + return new SuccessResponse("Profiler stopped.", new + { + enabled = false, + previous_log_file = previousLogFile, + }); + } + + internal static object Status(JObject @params) + { + var areas = new Dictionary(); + foreach (string name in AreaNames) + { + if (Enum.TryParse(name, out var area)) + areas[name] = Profiler.GetAreaEnabled(area); + } + + return new SuccessResponse("Profiler status.", new + { + enabled = Profiler.enabled, + recording = Profiler.enableBinaryLog, + log_file = Profiler.enableBinaryLog ? Profiler.logFile : null, + allocation_callstacks = Profiler.enableAllocationCallstacks, + areas, + }); + } + + internal static object SetAreas(JObject @params) + { + var areasToken = @params["areas"] as JObject; + if (areasToken == null) + return new ErrorResponse($"'areas' parameter required. Valid areas: {string.Join(", ", AreaNames)}"); + + var updated = new Dictionary(); + foreach (var prop in areasToken.Properties()) + { + if (!Enum.TryParse(prop.Name, true, out var area)) + return new ErrorResponse($"Unknown area '{prop.Name}'. Valid: {string.Join(", ", AreaNames)}"); + + bool enabled = prop.Value.ToObject(); + Profiler.SetAreaEnabled(area, enabled); + updated[prop.Name] = enabled; + } + + return new SuccessResponse($"Updated {updated.Count} profiler area(s).", new { areas = updated }); + } + } +} From 09bfced11ca1aafa9b254457b658c190a31cbb4b Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:55:20 -0400 Subject: [PATCH 09/18] feat(profiler): add FrameTimingOps using FrameTimingManager (sync, 12 fields) --- .../Profiler/Operations/FrameTimingOps.cs | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs index 45083f96e..a11ded2e8 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs @@ -1,36 +1,50 @@ -using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; -using Unity.Profiling; +using UnityEngine; namespace MCPForUnity.Editor.Tools.Profiler { internal static class FrameTimingOps { - private static readonly (string counterName, string valueKey, string validKey)[] COUNTER_MAP = new[] - { - ("CPU Main Thread Frame Time", "main_thread_ms", "main_thread_valid"), - ("CPU Render Thread Frame Time", "render_thread_ms", "render_thread_valid"), - ("CPU Total Frame Time", "cpu_frame_ms", "cpu_frame_valid"), - ("GPU Frame Time", "gpu_frame_ms", "gpu_frame_valid"), - }; - internal static object GetFrameTiming(JObject @params) { - var data = new Dictionary(); + if (!FrameTimingManager.IsFeatureEnabled()) + { + return new ErrorResponse( + "Frame Timing Stats is not enabled. " + + "Enable it in Project Settings > Player > Other Settings > 'Frame Timing Stats', " + + "or use a Development Build (always enabled)."); + } + + FrameTimingManager.CaptureFrameTimings(); + var timings = new FrameTiming[1]; + uint count = FrameTimingManager.GetLatestTimings(1, timings); - foreach (var (counterName, valueKey, validKey) in COUNTER_MAP) + if (count == 0) { - using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Internal, counterName); - data[valueKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0; - data[validKey] = recorder.Valid; + return new SuccessResponse("No frame timing data available yet (need a few frames).", new + { + available = false, + }); } - return new + var t = timings[0]; + return new SuccessResponse("Frame timing captured.", new { - success = true, - message = "Frame timing captured.", - data - }; + available = true, + cpu_frame_time_ms = t.cpuFrameTime, + cpu_main_thread_frame_time_ms = t.cpuMainThreadFrameTime, + cpu_main_thread_present_wait_time_ms = t.cpuMainThreadPresentWaitTime, + cpu_render_thread_frame_time_ms = t.cpuRenderThreadFrameTime, + gpu_frame_time_ms = t.gpuFrameTime, + frame_start_timestamp = t.frameStartTimestamp, + first_submit_timestamp = t.firstSubmitTimestamp, + cpu_time_present_called = t.cpuTimePresentCalled, + cpu_time_frame_complete = t.cpuTimeFrameComplete, + height_scale = t.heightScale, + width_scale = t.widthScale, + sync_interval = t.syncInterval, + }); } } } From 0fe485bf325dd878fb70050e19d2b76eaf121ffe Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:57:17 -0400 Subject: [PATCH 10/18] feat(profiler): add CounterOps with async 1-frame-wait recorder pattern --- .../Tools/Profiler/Operations/CounterOps.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs new file mode 100644 index 000000000..35538ad23 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using Unity.Profiling; +using Unity.Profiling.LowLevel.Unsafe; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class CounterOps + { + internal static async Task GetCountersAsync(JObject @params) + { + var p = new ToolParams(@params); + var categoryResult = p.GetRequired("category"); + if (!categoryResult.IsSuccess) + return new ErrorResponse(categoryResult.ErrorMessage); + + string categoryName = categoryResult.Value; + ProfilerCategory category = ResolveCategory(categoryName); + + // Get counter names: explicit list or discover all in category + var counterNames = GetRequestedCounters(p, category); + if (counterNames.Count == 0) + return new SuccessResponse($"No counters found in category '{categoryName}'.", new + { + category = categoryName, + counters = new Dictionary() + }); + + // Start recorders + var recorders = new List(); + foreach (string name in counterNames) + { + recorders.Add(ProfilerRecorder.StartNew(category, name)); + } + + // Wait 1 frame for recorders to accumulate data + await WaitOneFrameAsync(); + + // Read values and dispose + var data = new Dictionary(); + for (int i = 0; i < recorders.Count; i++) + { + var recorder = recorders[i]; + string name = counterNames[i]; + data[name] = recorder.Valid ? recorder.CurrentValueAsDouble : 0.0; + data[name + "_valid"] = recorder.Valid; + data[name + "_unit"] = recorder.Valid ? recorder.UnitType.ToString() : "Unknown"; + recorder.Dispose(); + } + + return new SuccessResponse($"Captured {counterNames.Count} counter(s) from '{categoryName}'.", new + { + category = categoryName, + counters = data, + }); + } + + private static List GetRequestedCounters(ToolParams p, ProfilerCategory category) + { + var explicitCounters = p.GetStringArray("counters"); + if (explicitCounters != null && explicitCounters.Length > 0) + return explicitCounters.ToList(); + + var allHandles = new List(); + ProfilerRecorderHandle.GetAvailable(allHandles); + return allHandles + .Select(h => ProfilerRecorderHandle.GetDescription(h)) + .Where(d => string.Equals(d.Category.Name, category.Name, StringComparison.OrdinalIgnoreCase)) + .Select(d => d.Name) + .OrderBy(n => n) + .ToList(); + } + + private static Task WaitOneFrameAsync() + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void Tick() + { + EditorApplication.update -= Tick; + tcs.TrySetResult(true); + } + + EditorApplication.update += Tick; + try { EditorApplication.QueuePlayerLoopUpdate(); } catch { /* throttled editor */ } + return tcs.Task; + } + + private static ProfilerCategory ResolveCategory(string name) + { + switch (name.ToLowerInvariant()) + { + case "render": return ProfilerCategory.Render; + case "scripts": return ProfilerCategory.Scripts; + case "memory": return ProfilerCategory.Memory; + case "physics": return ProfilerCategory.Physics; + case "physics2d": return ProfilerCategory.Physics2D; + case "animation": return ProfilerCategory.Animation; + case "audio": return ProfilerCategory.Audio; + case "lighting": return ProfilerCategory.Lighting; + case "network": return ProfilerCategory.Network; + case "gui": case "ui": return ProfilerCategory.Gui; + case "ai": return ProfilerCategory.Ai; + case "video": return ProfilerCategory.Video; + case "loading": return ProfilerCategory.Loading; + case "input": return ProfilerCategory.Input; + case "vr": return ProfilerCategory.Vr; + case "internal": return ProfilerCategory.Internal; + case "particles": return ProfilerCategory.Particles; + case "fileio": return ProfilerCategory.FileIO; + case "virtualtexturing": return ProfilerCategory.VirtualTexturing; + default: return ProfilerCategory.Render; + } + } + } +} From 97937c176425d74f9a3394c028fd006e7d423e0b Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:00:12 -0400 Subject: [PATCH 11/18] feat(profiler): add ObjectMemoryOps for per-object memory queries --- .../Profiler/Operations/ObjectMemoryOps.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs new file mode 100644 index 000000000..ad4b7185c --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs @@ -0,0 +1,54 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.Profiling; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class ObjectMemoryOps + { + internal static object GetObjectMemory(JObject @params) + { + var p = new ToolParams(@params); + var objectPathResult = p.GetRequired("object_path"); + if (!objectPathResult.IsSuccess) + return new ErrorResponse(objectPathResult.ErrorMessage); + + string objectPath = objectPathResult.Value; + + // Try scene hierarchy first + var go = GameObject.Find(objectPath); + if (go != null) + { + long bytes = Profiler.GetRuntimeMemorySizeLong(go); + return new SuccessResponse($"Memory for '{objectPath}'.", new + { + object_name = go.name, + object_type = go.GetType().Name, + size_bytes = bytes, + size_mb = Math.Round(bytes / (1024.0 * 1024.0), 3), + source = "scene_hierarchy", + }); + } + + // Try asset path + var asset = AssetDatabase.LoadAssetAtPath(objectPath); + if (asset != null) + { + long bytes = Profiler.GetRuntimeMemorySizeLong(asset); + return new SuccessResponse($"Memory for '{objectPath}'.", new + { + object_name = asset.name, + object_type = asset.GetType().Name, + size_bytes = bytes, + size_mb = Math.Round(bytes / (1024.0 * 1024.0), 3), + source = "asset_database", + }); + } + + return new ErrorResponse($"Object not found at path '{objectPath}'. Try a scene hierarchy path (e.g. /Player/Mesh) or an asset path (e.g. Assets/Textures/hero.png)."); + } + } +} From a5fabeffc61901310cf7b2b4b1c9ea2edd06ecac Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:14:10 -0400 Subject: [PATCH 12/18] feat(profiler): add MemorySnapshotOps with take/list/compare via reflection --- .../Profiler/Operations/MemorySnapshotOps.cs | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs new file mode 100644 index 000000000..c4d61ea19 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class MemorySnapshotOps + { + private static readonly Type MemoryProfilerType = + Type.GetType("Unity.MemoryProfiler.MemoryProfiler, Unity.MemoryProfiler.Editor"); + + private static bool HasPackage => MemoryProfilerType != null; + + internal static async Task TakeSnapshotAsync(JObject @params) + { + if (!HasPackage) + return PackageMissingError(); + + var p = new ToolParams(@params); + string snapshotPath = p.Get("snapshot_path"); + + if (string.IsNullOrEmpty(snapshotPath)) + { + string dir = Path.Combine(Application.temporaryCachePath, "MemoryCaptures"); + Directory.CreateDirectory(dir); + snapshotPath = Path.Combine(dir, $"snapshot_{DateTime.Now:yyyyMMdd_HHmmss}.snap"); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + try + { + var takeMethod = MemoryProfilerType.GetMethod("TakeSnapshot", + new[] { typeof(string), typeof(Action), typeof(Action), typeof(uint) }); + + if (takeMethod == null) + takeMethod = MemoryProfilerType.GetMethod("TakeSnapshot"); + + if (takeMethod == null) + return new ErrorResponse("Could not find TakeSnapshot method on MemoryProfiler. API may have changed."); + + Action callback = (path, result) => + { + if (result) + { + var fi = new FileInfo(path); + tcs.TrySetResult(new SuccessResponse("Memory snapshot captured.", new + { + path, + size_bytes = fi.Exists ? fi.Length : 0, + size_mb = fi.Exists ? Math.Round(fi.Length / (1024.0 * 1024.0), 2) : 0, + })); + } + else + { + tcs.TrySetResult(new ErrorResponse($"Snapshot capture failed for path: {path}")); + } + }; + + takeMethod.Invoke(null, new object[] { snapshotPath, callback, null, 0u }); + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to take snapshot: {ex.Message}"); + } + + var timeout = Task.Delay(TimeSpan.FromSeconds(30)); + var completed = await Task.WhenAny(tcs.Task, timeout); + if (completed == timeout) + return new ErrorResponse("Snapshot timed out after 30 seconds."); + + return await tcs.Task; + } + + internal static object ListSnapshots(JObject @params) + { + if (!HasPackage) + return PackageMissingError(); + + var p = new ToolParams(@params); + string searchPath = p.Get("search_path"); + + var dirs = new List(); + if (!string.IsNullOrEmpty(searchPath)) + { + dirs.Add(searchPath); + } + else + { + dirs.Add(Path.Combine(Application.temporaryCachePath, "MemoryCaptures")); + dirs.Add(Path.Combine(Application.dataPath, "..", "MemoryCaptures")); + } + + var snapshots = new List(); + foreach (string dir in dirs) + { + if (!Directory.Exists(dir)) continue; + foreach (string file in Directory.GetFiles(dir, "*.snap")) + { + var fi = new FileInfo(file); + snapshots.Add(new + { + path = fi.FullName, + size_bytes = fi.Length, + size_mb = Math.Round(fi.Length / (1024.0 * 1024.0), 2), + created = fi.CreationTimeUtc.ToString("o"), + }); + } + } + + return new SuccessResponse($"Found {snapshots.Count} snapshot(s).", new + { + snapshots, + searched_dirs = dirs, + }); + } + + internal static object CompareSnapshots(JObject @params) + { + if (!HasPackage) + return PackageMissingError(); + + var p = new ToolParams(@params); + var pathAResult = p.GetRequired("snapshot_a"); + if (!pathAResult.IsSuccess) + return new ErrorResponse(pathAResult.ErrorMessage); + + var pathBResult = p.GetRequired("snapshot_b"); + if (!pathBResult.IsSuccess) + return new ErrorResponse(pathBResult.ErrorMessage); + + string pathA = pathAResult.Value; + string pathB = pathBResult.Value; + + if (!File.Exists(pathA)) + return new ErrorResponse($"Snapshot file not found: {pathA}"); + if (!File.Exists(pathB)) + return new ErrorResponse($"Snapshot file not found: {pathB}"); + + var fiA = new FileInfo(pathA); + var fiB = new FileInfo(pathB); + + return new SuccessResponse("Snapshot comparison (file-level metadata).", new + { + snapshot_a = new + { + path = fiA.FullName, + size_bytes = fiA.Length, + size_mb = Math.Round(fiA.Length / (1024.0 * 1024.0), 2), + created = fiA.CreationTimeUtc.ToString("o"), + }, + snapshot_b = new + { + path = fiB.FullName, + size_bytes = fiB.Length, + size_mb = Math.Round(fiB.Length / (1024.0 * 1024.0), 2), + created = fiB.CreationTimeUtc.ToString("o"), + }, + delta = new + { + size_delta_bytes = fiB.Length - fiA.Length, + size_delta_mb = Math.Round((fiB.Length - fiA.Length) / (1024.0 * 1024.0), 2), + time_delta_seconds = (fiB.CreationTimeUtc - fiA.CreationTimeUtc).TotalSeconds, + }, + note = "For detailed object-level comparison, open both snapshots in the Memory Profiler window.", + }); + } + + private static ErrorResponse PackageMissingError() + { + return new ErrorResponse( + "Package com.unity.memoryprofiler is required. " + + "Install via Package Manager or: manage_packages action=add_package package_id=com.unity.memoryprofiler"); + } + } +} From 4095937e97bff6aefd58d55007c99bb3805dcd44 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:14:19 -0400 Subject: [PATCH 13/18] feat(profiler): add FrameDebuggerOps with reflection-based event extraction --- .../Profiler/Operations/FrameDebuggerOps.cs | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs new file mode 100644 index 000000000..6e1e72a93 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class FrameDebuggerOps + { + private static readonly Type UtilType; + private static readonly PropertyInfo EventCountProp; + private static readonly MethodInfo EnableMethod; + private static readonly MethodInfo DisableMethod; + private static readonly MethodInfo GetEventDataMethod; + private static readonly bool ReflectionAvailable; + + static FrameDebuggerOps() + { + try + { + UtilType = Type.GetType("UnityEditorInternal.FrameDebuggerUtility, UnityEditor"); + if (UtilType != null) + { + EventCountProp = UtilType.GetProperty("eventsCount", BindingFlags.Public | BindingFlags.Static) + ?? UtilType.GetProperty("count", BindingFlags.Public | BindingFlags.Static); + EnableMethod = UtilType.GetMethod("SetEnabled", BindingFlags.Public | BindingFlags.Static); + DisableMethod = EnableMethod; // Same method, different arg + GetEventDataMethod = UtilType.GetMethod("GetFrameEventData", BindingFlags.Public | BindingFlags.Static); + } + ReflectionAvailable = UtilType != null && EventCountProp != null; + } + catch + { + ReflectionAvailable = false; + } + } + + internal static object Enable(JObject @params) + { + if (!ReflectionAvailable) + { + return new ErrorResponse( + "FrameDebuggerUtility not available via reflection in this Unity version."); + } + + try + { + if (EnableMethod != null) + { + EnableMethod.Invoke(null, new object[] { true, 0 }); + } + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to enable Frame Debugger: {ex.Message}"); + } + + int eventCount = 0; + string warning = null; + try + { + eventCount = (int)EventCountProp.GetValue(null); + } + catch (Exception ex) + { + warning = $"Could not read event count: {ex.Message}"; + } + + var data = new Dictionary + { + ["enabled"] = true, + ["event_count"] = eventCount, + }; + if (warning != null) + data["warning"] = warning; + + return new SuccessResponse("Frame Debugger enabled.", data); + } + + internal static object Disable(JObject @params) + { + if (!ReflectionAvailable) + { + return new ErrorResponse( + "FrameDebuggerUtility not available via reflection in this Unity version."); + } + + try + { + if (EnableMethod != null) + { + EnableMethod.Invoke(null, new object[] { false, 0 }); + } + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to disable Frame Debugger: {ex.Message}"); + } + + return new SuccessResponse("Frame Debugger disabled.", new { enabled = false }); + } + + internal static object GetEvents(JObject @params) + { + if (!ReflectionAvailable || GetEventDataMethod == null) + { + return new SuccessResponse("Frame Debugger events (reflection unavailable).", new + { + events = new List(), + total_events = 0, + warning = "FrameDebuggerUtility API not available in this Unity version. " + + "Event data cannot be extracted programmatically.", + }); + } + + var p = new ToolParams(@params); + int pageSize = p.GetInt("page_size") ?? 50; + int cursor = p.GetInt("cursor") ?? 0; + + int totalEvents = 0; + try + { + totalEvents = (int)EventCountProp.GetValue(null); + } + catch + { + return new SuccessResponse("Could not read event count.", new + { + events = new List(), + total_events = 0, + warning = "Failed to read event count via reflection.", + }); + } + + if (totalEvents == 0) + { + return new SuccessResponse("Frame Debugger has no events. Is it enabled?", new + { + events = new List(), + total_events = 0, + }); + } + + var events = new List(); + int end = Math.Min(cursor + pageSize, totalEvents); + + for (int i = cursor; i < end; i++) + { + try + { + var eventData = GetEventDataMethod.Invoke(null, new object[] { i }); + if (eventData != null) + { + var eventType = eventData.GetType(); + var entry = new Dictionary { ["index"] = i }; + + TryAddField(eventType, eventData, "shaderName", entry); + TryAddField(eventType, eventData, "passName", entry); + TryAddField(eventType, eventData, "rtName", entry); + TryAddField(eventType, eventData, "rtWidth", entry); + TryAddField(eventType, eventData, "rtHeight", entry); + TryAddField(eventType, eventData, "vertexCount", entry); + TryAddField(eventType, eventData, "indexCount", entry); + TryAddField(eventType, eventData, "instanceCount", entry); + TryAddField(eventType, eventData, "meshName", entry); + + events.Add(entry); + } + } + catch + { + events.Add(new Dictionary + { + ["index"] = i, + ["error"] = "Failed to read event data", + }); + } + } + + var result = new Dictionary + { + ["events"] = events, + ["total_events"] = totalEvents, + ["page_size"] = pageSize, + ["cursor"] = cursor, + }; + if (end < totalEvents) + result["next_cursor"] = end; + + return new SuccessResponse($"Frame Debugger events {cursor}-{end - 1} of {totalEvents}.", result); + } + + private static void TryAddField(Type type, object obj, string fieldName, Dictionary dict) + { + try + { + var field = type.GetField(fieldName, BindingFlags.Public | BindingFlags.Instance) + ?? type.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + var prop = type.GetProperty(fieldName, BindingFlags.Public | BindingFlags.Instance) + ?? type.GetProperty(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + if (field != null) + dict[fieldName] = field.GetValue(obj); + else if (prop != null) + dict[fieldName] = prop.GetValue(obj); + } + catch { /* skip unavailable fields */ } + } + } +} From 1f0fb1d8bced966e1fcc5c514a2096f7b64f9b63 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:21:51 -0400 Subject: [PATCH 14/18] feat(profiler): rewrite CLI with 14 subcommands, register in main --- Server/src/cli/commands/profiler.py | 152 ++++++++++++++++++++++++---- Server/src/cli/main.py | 1 + 2 files changed, 135 insertions(+), 18 deletions(-) diff --git a/Server/src/cli/commands/profiler.py b/Server/src/cli/commands/profiler.py index 6e94da1e4..ba95a41cb 100644 --- a/Server/src/cli/commands/profiler.py +++ b/Server/src/cli/commands/profiler.py @@ -5,50 +5,166 @@ @click.group("profiler") def profiler(): - """Read Unity Profiler counters: CPU timing, GC allocation, and animation.""" + """Unity Profiler session control, counter reads, memory snapshots, and Frame Debugger.""" pass +# --- Session --- + +@profiler.command("start") +@click.option("--log-file", default=None, help="Path to .raw file for recording.") +@click.option("--callstacks", is_flag=True, default=False, help="Enable allocation callstacks.") +@handle_unity_errors +def start(log_file, callstacks): + """Start the Unity Profiler, optionally record to a .raw file.""" + config = get_config() + params = {"action": "profiler_start"} + if log_file: + params["log_file"] = log_file + if callstacks: + params["enable_callstacks"] = True + result = run_command("manage_profiler", params, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("stop") +@handle_unity_errors +def stop(): + """Stop the Unity Profiler and any active recording.""" + config = get_config() + result = run_command("manage_profiler", {"action": "profiler_stop"}, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("status") +@handle_unity_errors +def status(): + """Get Profiler enabled state, active areas, and recording status.""" + config = get_config() + result = run_command("manage_profiler", {"action": "profiler_status"}, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("set-areas") +@click.option("--area", multiple=True, help="Area=bool pairs (e.g. CPU=true Audio=false).") +@handle_unity_errors +def set_areas(area): + """Toggle specific ProfilerAreas on or off.""" + config = get_config() + areas = {} + for a in area: + name, _, val = a.partition("=") + areas[name.strip()] = val.strip().lower() in ("true", "1", "yes") + result = run_command("manage_profiler", {"action": "profiler_set_areas", "areas": areas}, config) + click.echo(format_output(result, config.format)) + + +# --- Counters --- + @profiler.command("frame-timing") @handle_unity_errors def frame_timing(): - """Get main thread, render thread, CPU and GPU frame timing (ms).""" + """Get frame timing via FrameTimingManager (12 fields, synchronous).""" config = get_config() result = run_command("manage_profiler", {"action": "get_frame_timing"}, config) click.echo(format_output(result, config.format)) -@profiler.command("script-timing") +@profiler.command("get-counters") +@click.option("--category", required=True, help="Profiler category (e.g. Render, Scripts, Memory).") +@click.option("--counter", multiple=True, help="Specific counter names. Omit to read all in category.") @handle_unity_errors -def script_timing(): - """Get Update, FixedUpdate, and LateUpdate script execution time (ms).""" +def get_counters(category, counter): + """Read profiler counters by category (async, 1-frame wait).""" config = get_config() - result = run_command("manage_profiler", {"action": "get_script_timing"}, config) + params = {"action": "get_counters", "category": category} + if counter: + params["counters"] = list(counter) + result = run_command("manage_profiler", params, config) click.echo(format_output(result, config.format)) -@profiler.command("physics-timing") +@profiler.command("object-memory") +@click.option("--path", required=True, help="Scene hierarchy or asset path.") +@handle_unity_errors +def object_memory(path): + """Get native memory size of a specific Unity object.""" + config = get_config() + result = run_command("manage_profiler", {"action": "get_object_memory", "object_path": path}, config) + click.echo(format_output(result, config.format)) + + +# --- Memory Snapshot --- + +@profiler.command("memory-snapshot") +@click.option("--path", default=None, help="Output .snap file path (default: auto-generated).") +@handle_unity_errors +def memory_snapshot(path): + """Take a memory snapshot (requires com.unity.memoryprofiler).""" + config = get_config() + params = {"action": "memory_take_snapshot"} + if path: + params["snapshot_path"] = path + result = run_command("manage_profiler", params, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("memory-list") +@click.option("--search-path", default=None, help="Directory to search for snapshots.") +@handle_unity_errors +def memory_list(search_path): + """List available memory snapshot files.""" + config = get_config() + params = {"action": "memory_list_snapshots"} + if search_path: + params["search_path"] = search_path + result = run_command("manage_profiler", params, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("memory-compare") +@click.option("--a", "snapshot_a", required=True, help="First snapshot path.") +@click.option("--b", "snapshot_b", required=True, help="Second snapshot path.") +@handle_unity_errors +def memory_compare(snapshot_a, snapshot_b): + """Compare two memory snapshots.""" + config = get_config() + result = run_command("manage_profiler", { + "action": "memory_compare_snapshots", + "snapshot_a": snapshot_a, "snapshot_b": snapshot_b, + }, config) + click.echo(format_output(result, config.format)) + + +# --- Frame Debugger --- + +@profiler.command("frame-debugger-enable") @handle_unity_errors -def physics_timing(): - """Get Physics.Simulate and Physics2D.Simulate time (ms).""" +def frame_debugger_enable(): + """Enable the Frame Debugger and report event count.""" config = get_config() - result = run_command("manage_profiler", {"action": "get_physics_timing"}, config) + result = run_command("manage_profiler", {"action": "frame_debugger_enable"}, config) click.echo(format_output(result, config.format)) -@profiler.command("gc-alloc") +@profiler.command("frame-debugger-disable") @handle_unity_errors -def gc_alloc(): - """Get GC allocation bytes and count per frame.""" +def frame_debugger_disable(): + """Disable the Frame Debugger.""" config = get_config() - result = run_command("manage_profiler", {"action": "get_gc_alloc"}, config) + result = run_command("manage_profiler", {"action": "frame_debugger_disable"}, config) click.echo(format_output(result, config.format)) -@profiler.command("animation-timing") +@profiler.command("frame-debugger-events") +@click.option("--page-size", default=50, help="Events per page (default 50).") +@click.option("--cursor", default=None, type=int, help="Cursor offset.") @handle_unity_errors -def animation_timing(): - """Get Animator.Update time (ms).""" +def frame_debugger_events(page_size, cursor): + """Get Frame Debugger draw call events (paged).""" config = get_config() - result = run_command("manage_profiler", {"action": "get_animation_timing"}, config) + params = {"action": "frame_debugger_get_events", "page_size": page_size} + if cursor is not None: + params["cursor"] = cursor + result = run_command("manage_profiler", params, config) click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index a13c3de86..44afce32c 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -274,6 +274,7 @@ def register_optional_command(module_name: str, command_name: str) -> None: ("cli.commands.reflect", "reflect"), ("cli.commands.docs", "docs"), ("cli.commands.physics", "physics"), + ("cli.commands.profiler", "profiler"), ] for module_name, command_name in optional_commands: From 25357a0e75b97169d6bca1569df300915e182c7a Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:10:03 -0400 Subject: [PATCH 15/18] =?UTF-8?q?fix(profiler):=20address=20code=20review?= =?UTF-8?q?=20=E2=80=94=20error=20on=20unknown=20category,=20tighten=20ref?= =?UTF-8?q?lection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../references/tools-reference.md | 243 +++++++++++++++--- .../Tools/Profiler/Operations/CounterOps.cs | 19 +- .../Profiler/Operations/FrameDebuggerOps.cs | 10 +- .../Profiler/Operations/MemorySnapshotOps.cs | 14 +- README.md | 5 +- docs/i18n/README-zh.md | 5 +- manifest.json | 4 + unity-mcp-skill/references/tools-reference.md | 72 ++++++ 8 files changed, 320 insertions(+), 52 deletions(-) diff --git a/.claude/skills/unity-mcp-skill/references/tools-reference.md b/.claude/skills/unity-mcp-skill/references/tools-reference.md index bc100e25a..601e533a9 100644 --- a/.claude/skills/unity-mcp-skill/references/tools-reference.md +++ b/.claude/skills/unity-mcp-skill/references/tools-reference.md @@ -18,7 +18,9 @@ Complete reference for all MCP tools. Each tool includes parameters, types, and - [Camera Tools](#camera-tools) - [Graphics Tools](#graphics-tools) - [Package Tools](#package-tools) +- [Physics Tools](#physics-tools) - [ProBuilder Tools](#probuilder-tools) +- [Profiler Tools](#profiler-tools) - [Docs Tools](#docs-tools) --- @@ -175,26 +177,6 @@ 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 @@ -352,11 +334,6 @@ 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. ``` --- @@ -549,23 +526,27 @@ manage_prefabs( components_to_add=["AudioSource"] ) -# Add child GameObjects to a prefab (single or batch) +# Delete child GameObjects from prefab 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"} - ] + delete_child=["OldChild", "Turret/Barrel"] # single string or list +) + +# Create child GameObject in prefab +manage_prefabs( + action="modify_contents", + prefab_path="Assets/Prefabs/Player.prefab", + create_child={"name": "SpawnPoint", "primitive_type": "Sphere", "position": [0, 2, 0]} ) -# Add a nested prefab instance inside a prefab +# Set component properties on prefab contents 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]} + target="ChildObject", + component_properties={"Rigidbody": {"mass": 5.0}, "MyScript": {"health": 100}} ) -# source_prefab_path and primitive_type are mutually exclusive ``` --- @@ -734,7 +715,7 @@ manage_ui( ### manage_editor -Control Unity Editor state, undo/redo. +Control Unity Editor state. ```python manage_editor(action="play") # Enter play mode @@ -749,12 +730,10 @@ manage_editor(action="remove_tag", tag_name="OldTag") manage_editor(action="add_layer", layer_name="Projectiles") manage_editor(action="remove_layer", layer_name="OldLayer") +manage_editor(action="open_prefab_stage", prefab_path="Assets/Prefabs/Enemy.prefab") +manage_editor(action="save_prefab_stage") # Save changes in the open prefab stage 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 @@ -1215,6 +1194,121 @@ manage_packages( --- +## Physics Tools + +### `manage_physics` + +Manage 3D and 2D physics: settings, collision matrix, materials, joints, queries, validation, and simulation. All actions support `dimension="3d"` (default) or `dimension="2d"` where applicable. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | string | Yes | See action groups below | +| `dimension` | string | No | `"3d"` (default) or `"2d"` | +| `settings` | object | For set_settings | Key-value physics settings dict | +| `layer_a` / `layer_b` | string | For collision matrix | Layer name or index | +| `collide` | bool | For set_collision_matrix | `true` to enable, `false` to disable | +| `name` | string | For create_physics_material | Material asset name | +| `path` | string | No | Asset folder path (create) or asset path (configure) | +| `dynamic_friction` / `static_friction` / `bounciness` | float | No | Material properties (0–1) | +| `friction_combine` / `bounce_combine` | string | No | `Average`, `Minimum`, `Multiply`, `Maximum` | +| `material_path` | string | For assign_physics_material | Path to physics material asset | +| `target` | string | For joints/queries/validate | GameObject name or instance ID | +| `joint_type` | string | For joints | 3D: `fixed`, `hinge`, `spring`, `character`, `configurable`; 2D: `distance`, `fixed`, `friction`, `hinge`, `relative`, `slider`, `spring`, `target`, `wheel` | +| `connected_body` | string | For add_joint | Connected body GameObject | +| `motor` / `limits` / `spring` / `drive` | object | For configure_joint | Joint sub-config objects | +| `properties` | object | For configure_joint/material | Direct property dict | +| `origin` / `direction` | float[] | For raycast | Ray origin and direction `[x,y,z]` or `[x,y]` | +| `max_distance` | float | No | Max raycast distance | +| `shape` | string | For overlap | `sphere`, `box`, `capsule` (3D); `circle`, `box`, `capsule` (2D) | +| `position` | float[] | For overlap | `[x,y,z]` or `[x,y]` | +| `size` | float or float[] | For overlap | Radius (sphere/circle) or half-extents `[x,y,z]` (box) | +| `layer_mask` | string | No | Layer name or int mask for queries | +| `start` / `end` | float[] | For linecast | Start and end points `[x,y,z]` or `[x,y]` | +| `point1` / `point2` | float[] | For shapecast capsule | Capsule endpoints (3D alternative) | +| `height` | float | For shapecast capsule | Capsule height | +| `capsule_direction` | int | For shapecast capsule | 0=X, 1=Y (default), 2=Z | +| `angle` | float | For 2D shapecasts | Rotation angle in degrees | +| `force` | float[] | For apply_force | Force vector `[x,y,z]` or `[x,y]` | +| `force_mode` | string | For apply_force | `Force`, `Impulse`, `Acceleration`, `VelocityChange` (3D); `Force`, `Impulse` (2D) | +| `force_type` | string | For apply_force | `normal` (default) or `explosion` (3D only) | +| `torque` | float[] | For apply_force | Torque `[x,y,z]` (3D) or `[z]` (2D) | +| `explosion_position` | float[] | For apply_force explosion | Explosion center `[x,y,z]` | +| `explosion_radius` | float | For apply_force explosion | Explosion sphere radius | +| `explosion_force` | float | For apply_force explosion | Explosion force magnitude | +| `upwards_modifier` | float | For apply_force explosion | Y-axis offset (default 0) | +| `steps` | int | For simulate_step | Number of steps (1–100) | +| `step_size` | float | No | Step size in seconds (default: `Time.fixedDeltaTime`) | + +**Action groups:** + +- **Settings:** `ping`, `get_settings`, `set_settings` +- **Collision Matrix:** `get_collision_matrix`, `set_collision_matrix` +- **Materials:** `create_physics_material`, `configure_physics_material`, `assign_physics_material` +- **Joints:** `add_joint`, `configure_joint`, `remove_joint` +- **Queries:** `raycast`, `raycast_all`, `linecast`, `shapecast`, `overlap` +- **Forces:** `apply_force` +- **Rigidbody:** `get_rigidbody`, `configure_rigidbody` +- **Validation:** `validate` +- **Simulation:** `simulate_step` + +```python +# Check physics status +manage_physics(action="ping") + +# Get/set gravity +manage_physics(action="get_settings", dimension="3d") +manage_physics(action="set_settings", dimension="3d", settings={"gravity": [0, -20, 0]}) + +# Collision matrix +manage_physics(action="get_collision_matrix") +manage_physics(action="set_collision_matrix", layer_a="Player", layer_b="Enemy", collide=False) + +# Create a bouncy physics material and assign it +manage_physics(action="create_physics_material", name="Bouncy", bounciness=0.9, dynamic_friction=0.2) +manage_physics(action="assign_physics_material", target="Ball", material_path="Assets/Physics Materials/Bouncy.physicMaterial") + +# Add and configure a hinge joint +manage_physics(action="add_joint", target="Door", joint_type="hinge", connected_body="DoorFrame") +manage_physics(action="configure_joint", target="Door", joint_type="hinge", + motor={"targetVelocity": 90, "force": 100}, + limits={"min": -90, "max": 0, "bounciness": 0}) + +# Raycast and overlap +manage_physics(action="raycast", origin=[0, 10, 0], direction=[0, -1, 0], max_distance=50) +manage_physics(action="overlap", shape="sphere", position=[0, 0, 0], size=5.0) + +# Validate scene physics setup +manage_physics(action="validate") # whole scene +manage_physics(action="validate", target="Player") # single object + +# Multi-hit raycast (returns all hits sorted by distance) +manage_physics(action="raycast_all", origin=[0, 10, 0], direction=[0, -1, 0]) + +# Linecast (point A to point B) +manage_physics(action="linecast", start=[0, 0, 0], end=[10, 0, 0]) + +# Shapecast (sphere/box/capsule sweep) +manage_physics(action="shapecast", shape="sphere", origin=[0, 5, 0], direction=[0, -1, 0], size=0.5) +manage_physics(action="shapecast", shape="box", origin=[0, 5, 0], direction=[0, -1, 0], size=[1, 1, 1]) + +# Apply force (works with simulate_step for edit-mode previewing) +manage_physics(action="apply_force", target="Ball", force=[0, 500, 0], force_mode="Impulse") +manage_physics(action="apply_force", target="Ball", torque=[0, 10, 0]) + +# Explosion force (3D only) +manage_physics(action="apply_force", target="Crate", force_type="explosion", + explosion_force=1000, explosion_position=[0, 0, 0], explosion_radius=10) + +# Configure rigidbody properties +manage_physics(action="configure_rigidbody", target="Player", + properties={"mass": 80, "drag": 0.5, "useGravity": True, "collisionDetectionMode": "Continuous"}) + +# Step physics in edit mode +manage_physics(action="simulate_step", steps=10, step_size=0.02) +``` + +--- + ## ProBuilder Tools ### manage_probuilder @@ -1330,6 +1424,77 @@ See also: [ProBuilder Workflow Guide](probuilder-guide.md) for detailed patterns --- +## Profiler Tools + +### `manage_profiler` + +Unity Profiler session control, counter reads, memory snapshots, and Frame Debugger. Group: `profiling` (opt-in via `manage_tools`). + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | string | Yes | See action groups below | +| `category` | string | For get_counters | Profiler category name (e.g. `Render`, `Scripts`, `Memory`, `Physics`) | +| `counters` | list[str] | No | Specific counter names for get_counters. Omit to read all in category | +| `object_path` | string | For get_object_memory | Scene hierarchy or asset path | +| `log_file` | string | No | Path to `.raw` file for profiler_start recording | +| `enable_callstacks` | bool | No | Enable allocation callstacks for profiler_start | +| `areas` | dict[str, bool] | For profiler_set_areas | Area name to enabled/disabled mapping | +| `snapshot_path` | string | No | Output path for memory_take_snapshot | +| `search_path` | string | No | Search directory for memory_list_snapshots | +| `snapshot_a` | string | For memory_compare_snapshots | First snapshot file path | +| `snapshot_b` | string | For memory_compare_snapshots | Second snapshot file path | +| `page_size` | int | No | Page size for frame_debugger_get_events (default 50) | +| `cursor` | int | No | Cursor offset for frame_debugger_get_events | + +**Action groups:** + +- **Session:** `profiler_start`, `profiler_stop`, `profiler_status`, `profiler_set_areas` +- **Counters:** `get_frame_timing`, `get_counters`, `get_object_memory` +- **Memory Snapshot:** `memory_take_snapshot`, `memory_list_snapshots`, `memory_compare_snapshots` (requires `com.unity.memoryprofiler`) +- **Frame Debugger:** `frame_debugger_enable`, `frame_debugger_disable`, `frame_debugger_get_events` +- **Utility:** `ping` + +```python +# Check profiler availability +manage_profiler(action="ping") + +# Start profiling (optionally record to file) +manage_profiler(action="profiler_start") +manage_profiler(action="profiler_start", log_file="Assets/profiler.raw", enable_callstacks=True) + +# Check profiler status +manage_profiler(action="profiler_status") + +# Toggle profiler areas +manage_profiler(action="profiler_set_areas", areas={"CPU": True, "GPU": True, "Rendering": True, "Memory": False}) + +# Stop profiling +manage_profiler(action="profiler_stop") + +# Read frame timing data (12 fields from FrameTimingManager) +manage_profiler(action="get_frame_timing") + +# Read counters by category +manage_profiler(action="get_counters", category="Render") +manage_profiler(action="get_counters", category="Memory", counters=["Total Used Memory", "GC Used Memory"]) + +# Get memory size of a specific object +manage_profiler(action="get_object_memory", object_path="Player/Mesh") + +# Memory snapshots (requires com.unity.memoryprofiler) +manage_profiler(action="memory_take_snapshot") +manage_profiler(action="memory_take_snapshot", snapshot_path="Assets/Snapshots/baseline.snap") +manage_profiler(action="memory_list_snapshots") +manage_profiler(action="memory_compare_snapshots", snapshot_a="Assets/Snapshots/before.snap", snapshot_b="Assets/Snapshots/after.snap") + +# Frame Debugger +manage_profiler(action="frame_debugger_enable") +manage_profiler(action="frame_debugger_get_events", page_size=20, cursor=0) +manage_profiler(action="frame_debugger_disable") +``` + +--- + ## Docs Tools Tools for verifying Unity C# APIs and fetching official documentation. Group: `docs`. @@ -1391,7 +1556,7 @@ No Unity connection needed for doc fetching. The `lookup` action with asset-rela - **`get_doc`**: Fetch ScriptReference docs for a class or member. Parses HTML to extract description, signatures, parameters, return type, and code examples. - **`get_manual`**: Fetch a Unity Manual page by slug. Returns title, sections, and code examples. - **`get_package_doc`**: Fetch package documentation. Requires package name, page slug, and package version. -- **`lookup`**: Search all doc sources in parallel (ScriptReference + Manual + package docs). Supports batch queries. For asset-related queries (shader, material, texture, etc.), also searches project assets via `manage_asset`. +- **`lookup`**: Search doc sources in parallel (ScriptReference + Manual; also package docs if `package` + `pkg_version` provided). Supports batch queries. For asset-related queries (shader, material, texture, etc.), also searches project assets via `manage_asset`. ```python # Fetch ScriptReference for a class diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs index 35538ad23..296886374 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs @@ -20,7 +20,10 @@ internal static async Task GetCountersAsync(JObject @params) return new ErrorResponse(categoryResult.ErrorMessage); string categoryName = categoryResult.Value; - ProfilerCategory category = ResolveCategory(categoryName); + var resolved = ResolveCategory(categoryName, out string categoryError); + if (resolved == null) + return new ErrorResponse(categoryError); + ProfilerCategory category = resolved.Value; // Get counter names: explicit list or discover all in category var counterNames = GetRequestedCounters(p, category); @@ -91,8 +94,16 @@ void Tick() return tcs.Task; } - private static ProfilerCategory ResolveCategory(string name) + private static readonly string[] ValidCategories = new[] { + "Render", "Scripts", "Memory", "Physics", "Physics2D", "Animation", + "Audio", "Lighting", "Network", "Gui", "UI", "Ai", "Video", + "Loading", "Input", "Vr", "Internal", "Particles", "FileIO", "VirtualTexturing" + }; + + internal static ProfilerCategory? ResolveCategory(string name, out string error) + { + error = null; switch (name.ToLowerInvariant()) { case "render": return ProfilerCategory.Render; @@ -114,7 +125,9 @@ private static ProfilerCategory ResolveCategory(string name) case "particles": return ProfilerCategory.Particles; case "fileio": return ProfilerCategory.FileIO; case "virtualtexturing": return ProfilerCategory.VirtualTexturing; - default: return ProfilerCategory.Render; + default: + error = $"Unknown category '{name}'. Valid: {string.Join(", ", ValidCategories)}"; + return null; } } } diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs index 6e1e72a93..b6b031686 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs @@ -11,7 +11,6 @@ internal static class FrameDebuggerOps private static readonly Type UtilType; private static readonly PropertyInfo EventCountProp; private static readonly MethodInfo EnableMethod; - private static readonly MethodInfo DisableMethod; private static readonly MethodInfo GetEventDataMethod; private static readonly bool ReflectionAvailable; @@ -24,9 +23,12 @@ static FrameDebuggerOps() { EventCountProp = UtilType.GetProperty("eventsCount", BindingFlags.Public | BindingFlags.Static) ?? UtilType.GetProperty("count", BindingFlags.Public | BindingFlags.Static); - EnableMethod = UtilType.GetMethod("SetEnabled", BindingFlags.Public | BindingFlags.Static); - DisableMethod = EnableMethod; // Same method, different arg - GetEventDataMethod = UtilType.GetMethod("GetFrameEventData", BindingFlags.Public | BindingFlags.Static); + EnableMethod = UtilType.GetMethod("SetEnabled", BindingFlags.Public | BindingFlags.Static, + null, new[] { typeof(bool), typeof(int) }, null) + ?? UtilType.GetMethod("SetEnabled", BindingFlags.Public | BindingFlags.Static); + GetEventDataMethod = UtilType.GetMethod("GetFrameEventData", BindingFlags.Public | BindingFlags.Static, + null, new[] { typeof(int) }, null) + ?? UtilType.GetMethod("GetFrameEventData", BindingFlags.Public | BindingFlags.Static); } ReflectionAvailable = UtilType != null && EventCountProp != null; } diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs index c4d61ea19..f67881fbe 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs @@ -38,7 +38,11 @@ internal static async Task TakeSnapshotAsync(JObject @params) new[] { typeof(string), typeof(Action), typeof(Action), typeof(uint) }); if (takeMethod == null) - takeMethod = MemoryProfilerType.GetMethod("TakeSnapshot"); + { + // Try 2-param overload: TakeSnapshot(string, Action) + takeMethod = MemoryProfilerType.GetMethod("TakeSnapshot", + new[] { typeof(string), typeof(Action) }); + } if (takeMethod == null) return new ErrorResponse("Could not find TakeSnapshot method on MemoryProfiler. API may have changed."); @@ -61,7 +65,13 @@ internal static async Task TakeSnapshotAsync(JObject @params) } }; - takeMethod.Invoke(null, new object[] { snapshotPath, callback, null, 0u }); + int paramCount = takeMethod.GetParameters().Length; + if (paramCount == 4) + takeMethod.Invoke(null, new object[] { snapshotPath, callback, null, 0u }); + else if (paramCount == 2) + takeMethod.Invoke(null, new object[] { snapshotPath, callback }); + else + return new ErrorResponse($"TakeSnapshot has unexpected {paramCount} parameters. API may have changed."); } catch (Exception ex) { diff --git a/README.md b/README.md index 553c8dde2..f16cc4dda 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@
Recent Updates -* **v9.6.2 (beta)** — New `manage_physics` tool (21 actions): physics settings, layer collision matrix, physics materials, joints (5 3D + 9 2D types), queries (raycast, raycast_all, linecast, shapecast, overlap), force application (AddForce/AddTorque/AddExplosionForce), rigidbody configuration, scene-wide validation, and edit-mode simulation. Full 3D and 2D support. +* **v9.6.3 (beta)** — New `manage_profiler` tool (14 actions): Profiler session control (start/stop/status/set areas), frame timing and counter reads, object memory queries, memory snapshots (take/list/compare via com.unity.memoryprofiler), and Frame Debugger (enable/disable/get events). Group: `profiling`. +* **v9.6.2** — New `manage_physics` tool (21 actions): physics settings, layer collision matrix, physics materials, joints (5 3D + 9 2D types), queries (raycast, raycast_all, linecast, shapecast, overlap), force application (AddForce/AddTorque/AddExplosionForce), rigidbody configuration, scene-wide validation, and edit-mode simulation. Full 3D and 2D support. * **v9.6.1** — 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`. @@ -96,7 +97,7 @@ openupm add com.coplaydev.unity-mcp * **Extensible** — Works with various MCP Clients ### Available Tools -`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_build` • `manage_camera` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_graphics` • `manage_material` • `manage_packages` • `manage_physics` • `manage_prefabs` • `manage_probuilder` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `unity_docs` • `unity_reflect` • `validate_script` +`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_build` • `manage_camera` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_graphics` • `manage_material` • `manage_packages` • `manage_physics` • `manage_prefabs` • `manage_probuilder` • `manage_profiler` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `unity_docs` • `unity_reflect` • `validate_script` ### Available Resources `cameras` • `custom_tools` • `renderer_features` • `rendering_stats` • `volumes` • `editor_active_tool` • `editor_prefab_stage` • `editor_selection` • `editor_state` • `editor_windows` • `gameobject` • `gameobject_api` • `gameobject_component` • `gameobject_components` • `get_tests` • `get_tests_for_mode` • `menu_items` • `prefab_api` • `prefab_hierarchy` • `prefab_info` • `project_info` • `project_layers` • `project_tags` • `tool_groups` • `unity_instances` diff --git a/docs/i18n/README-zh.md b/docs/i18n/README-zh.md index 7f1a665fc..7854f29ea 100644 --- a/docs/i18n/README-zh.md +++ b/docs/i18n/README-zh.md @@ -20,7 +20,8 @@
最近更新 -* **v9.6.2 (beta)** — 新增 `manage_physics` 工具(21个操作):物理设置、层碰撞矩阵、物理材质、关节(5种3D + 9种2D类型)、查询(raycast、raycast_all、linecast、shapecast、overlap)、力施加(AddForce/AddTorque/AddExplosionForce)、刚体配置、场景物理验证与编辑器模式模拟。全面支持3D和2D物理。 +* **v9.6.3 (beta)** — 新增 `manage_profiler` 工具(14个操作):Profiler 会话控制(启动/停止/状态/设置区域)、帧时间与计数器读取、对象内存查询、内存快照(通过 com.unity.memoryprofiler 进行拍摄/列表/比较)、帧调试器(启用/禁用/获取事件)。分组:`profiling`。 +* **v9.6.2** — 新增 `manage_physics` 工具(21个操作):物理设置、层碰撞矩阵、物理材质、关节(5种3D + 9种2D类型)、查询(raycast、raycast_all、linecast、shapecast、overlap)、力施加(AddForce/AddTorque/AddExplosionForce)、刚体配置、场景物理验证与编辑器模式模拟。全面支持3D和2D物理。 * **v9.6.1** — 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`。 @@ -96,7 +97,7 @@ openupm add com.coplaydev.unity-mcp * **可扩展** — 可与多种 MCP Client 配合使用 ### 可用工具 -`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_build` • `manage_camera` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_graphics` • `manage_material` • `manage_packages` • `manage_physics` • `manage_prefabs` • `manage_probuilder` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `unity_docs` • `unity_reflect` • `validate_script` +`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_build` • `manage_camera` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_graphics` • `manage_material` • `manage_packages` • `manage_physics` • `manage_prefabs` • `manage_probuilder` • `manage_profiler` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `unity_docs` • `unity_reflect` • `validate_script` ### 可用资源 `cameras` • `custom_tools` • `renderer_features` • `rendering_stats` • `volumes` • `editor_active_tool` • `editor_prefab_stage` • `editor_selection` • `editor_state` • `editor_windows` • `gameobject` • `gameobject_api` • `gameobject_component` • `gameobject_components` • `get_tests` • `get_tests_for_mode` • `menu_items` • `prefab_api` • `prefab_hierarchy` • `prefab_info` • `project_info` • `project_layers` • `project_tags` • `tool_groups` • `unity_instances` diff --git a/manifest.json b/manifest.json index 7ea0fbe72..ebc2d7759 100644 --- a/manifest.json +++ b/manifest.json @@ -125,6 +125,10 @@ "name": "manage_probuilder", "description": "Create and edit ProBuilder meshes, shapes, and geometry operations" }, + { + "name": "manage_profiler", + "description": "Unity Profiler session control, counter reads, memory snapshots, and Frame Debugger (group: profiling)" + }, { "name": "manage_scene", "description": "Load, save, query hierarchy, multi-scene editing, templates, validation, and manage Unity scenes" diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index afd71e06f..601e533a9 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -20,6 +20,7 @@ Complete reference for all MCP tools. Each tool includes parameters, types, and - [Package Tools](#package-tools) - [Physics Tools](#physics-tools) - [ProBuilder Tools](#probuilder-tools) +- [Profiler Tools](#profiler-tools) - [Docs Tools](#docs-tools) --- @@ -1423,6 +1424,77 @@ See also: [ProBuilder Workflow Guide](probuilder-guide.md) for detailed patterns --- +## Profiler Tools + +### `manage_profiler` + +Unity Profiler session control, counter reads, memory snapshots, and Frame Debugger. Group: `profiling` (opt-in via `manage_tools`). + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | string | Yes | See action groups below | +| `category` | string | For get_counters | Profiler category name (e.g. `Render`, `Scripts`, `Memory`, `Physics`) | +| `counters` | list[str] | No | Specific counter names for get_counters. Omit to read all in category | +| `object_path` | string | For get_object_memory | Scene hierarchy or asset path | +| `log_file` | string | No | Path to `.raw` file for profiler_start recording | +| `enable_callstacks` | bool | No | Enable allocation callstacks for profiler_start | +| `areas` | dict[str, bool] | For profiler_set_areas | Area name to enabled/disabled mapping | +| `snapshot_path` | string | No | Output path for memory_take_snapshot | +| `search_path` | string | No | Search directory for memory_list_snapshots | +| `snapshot_a` | string | For memory_compare_snapshots | First snapshot file path | +| `snapshot_b` | string | For memory_compare_snapshots | Second snapshot file path | +| `page_size` | int | No | Page size for frame_debugger_get_events (default 50) | +| `cursor` | int | No | Cursor offset for frame_debugger_get_events | + +**Action groups:** + +- **Session:** `profiler_start`, `profiler_stop`, `profiler_status`, `profiler_set_areas` +- **Counters:** `get_frame_timing`, `get_counters`, `get_object_memory` +- **Memory Snapshot:** `memory_take_snapshot`, `memory_list_snapshots`, `memory_compare_snapshots` (requires `com.unity.memoryprofiler`) +- **Frame Debugger:** `frame_debugger_enable`, `frame_debugger_disable`, `frame_debugger_get_events` +- **Utility:** `ping` + +```python +# Check profiler availability +manage_profiler(action="ping") + +# Start profiling (optionally record to file) +manage_profiler(action="profiler_start") +manage_profiler(action="profiler_start", log_file="Assets/profiler.raw", enable_callstacks=True) + +# Check profiler status +manage_profiler(action="profiler_status") + +# Toggle profiler areas +manage_profiler(action="profiler_set_areas", areas={"CPU": True, "GPU": True, "Rendering": True, "Memory": False}) + +# Stop profiling +manage_profiler(action="profiler_stop") + +# Read frame timing data (12 fields from FrameTimingManager) +manage_profiler(action="get_frame_timing") + +# Read counters by category +manage_profiler(action="get_counters", category="Render") +manage_profiler(action="get_counters", category="Memory", counters=["Total Used Memory", "GC Used Memory"]) + +# Get memory size of a specific object +manage_profiler(action="get_object_memory", object_path="Player/Mesh") + +# Memory snapshots (requires com.unity.memoryprofiler) +manage_profiler(action="memory_take_snapshot") +manage_profiler(action="memory_take_snapshot", snapshot_path="Assets/Snapshots/baseline.snap") +manage_profiler(action="memory_list_snapshots") +manage_profiler(action="memory_compare_snapshots", snapshot_a="Assets/Snapshots/before.snap", snapshot_b="Assets/Snapshots/after.snap") + +# Frame Debugger +manage_profiler(action="frame_debugger_enable") +manage_profiler(action="frame_debugger_get_events", page_size=20, cursor=0) +manage_profiler(action="frame_debugger_disable") +``` + +--- + ## Docs Tools Tools for verifying Unity C# APIs and fetching official documentation. Group: `docs`. From fbaa7ce946cba3f95404420cba334c4832c868d7 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:16:22 -0400 Subject: [PATCH 16/18] chore: bump version to 9.6.3-beta.11 --- MCPForUnity/package.json | 2 +- Server/README.md | 2 +- Server/pyproject.toml | 2 +- manifest.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MCPForUnity/package.json b/MCPForUnity/package.json index d78bfbab4..652370bd3 100644 --- a/MCPForUnity/package.json +++ b/MCPForUnity/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "9.6.3-beta.10", + "version": "9.6.3-beta.11", "displayName": "MCP for Unity", "description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3", diff --git a/Server/README.md b/Server/README.md index 8de67d60b..6cc6286a0 100644 --- a/Server/README.md +++ b/Server/README.md @@ -69,7 +69,7 @@ Use this to run the latest released version from the repository. Change the vers "command": "uvx", "args": [ "--from", - "git+https://github.com/CoplayDev/unity-mcp@v9.6.2#subdirectory=Server", + "git+https://github.com/CoplayDev/unity-mcp@v9.6.3-beta.11#subdirectory=Server", "mcp-for-unity", "--transport", "stdio" diff --git a/Server/pyproject.toml b/Server/pyproject.toml index ae7ad069c..b09083546 100644 --- a/Server/pyproject.toml +++ b/Server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcpforunityserver" -version = "9.6.2" +version = "9.6.3-beta.11" description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" license = "MIT" diff --git a/manifest.json b/manifest.json index ebc2d7759..373c1bfeb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": "0.3", "name": "Unity MCP", - "version": "9.6.2", + "version": "9.6.3-beta.11", "description": "AI-powered Unity Editor automation via MCP - manage GameObjects, scripts, materials, scenes, prefabs, VFX, and run tests", "author": { "name": "Coplay", From fa642359559c04c3f0350777cf856aace9a93eba Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:01:28 -0400 Subject: [PATCH 17/18] Revert "chore: bump version to 9.6.3-beta.11" This reverts commit fbaa7ce946cba3f95404420cba334c4832c868d7. --- MCPForUnity/package.json | 2 +- Server/README.md | 2 +- Server/pyproject.toml | 2 +- manifest.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MCPForUnity/package.json b/MCPForUnity/package.json index 652370bd3..d78bfbab4 100644 --- a/MCPForUnity/package.json +++ b/MCPForUnity/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "9.6.3-beta.11", + "version": "9.6.3-beta.10", "displayName": "MCP for Unity", "description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3", diff --git a/Server/README.md b/Server/README.md index 6cc6286a0..8de67d60b 100644 --- a/Server/README.md +++ b/Server/README.md @@ -69,7 +69,7 @@ Use this to run the latest released version from the repository. Change the vers "command": "uvx", "args": [ "--from", - "git+https://github.com/CoplayDev/unity-mcp@v9.6.3-beta.11#subdirectory=Server", + "git+https://github.com/CoplayDev/unity-mcp@v9.6.2#subdirectory=Server", "mcp-for-unity", "--transport", "stdio" diff --git a/Server/pyproject.toml b/Server/pyproject.toml index b09083546..ae7ad069c 100644 --- a/Server/pyproject.toml +++ b/Server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcpforunityserver" -version = "9.6.3-beta.11" +version = "9.6.2" description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" license = "MIT" diff --git a/manifest.json b/manifest.json index 373c1bfeb..ebc2d7759 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": "0.3", "name": "Unity MCP", - "version": "9.6.3-beta.11", + "version": "9.6.2", "description": "AI-powered Unity Editor automation via MCP - manage GameObjects, scripts, materials, scenes, prefabs, VFX, and run tests", "author": { "name": "Coplay", From c120fdd0e9ec2cd39220e1faf3b4c5251d3ab74b Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:27:42 -0400 Subject: [PATCH 18/18] =?UTF-8?q?fix(profiler):=20address=20AI=20review=20?= =?UTF-8?q?=E2=80=94=20try/finally=20disposal,=20safe=20reflection,=20type?= =?UTF-8?q?=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tools/Profiler/Operations/CounterOps.cs | 29 ++++++++++++------- .../Profiler/Operations/FrameDebuggerOps.cs | 17 +++++++++-- .../Profiler/Operations/MemorySnapshotOps.cs | 2 +- .../Tools/Profiler/Operations/SessionOps.cs | 8 +++-- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs index 296886374..7058c7c11 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs @@ -41,19 +41,26 @@ internal static async Task GetCountersAsync(JObject @params) recorders.Add(ProfilerRecorder.StartNew(category, name)); } - // Wait 1 frame for recorders to accumulate data - await WaitOneFrameAsync(); - - // Read values and dispose var data = new Dictionary(); - for (int i = 0; i < recorders.Count; i++) + try + { + // Wait 1 frame for recorders to accumulate data + await WaitOneFrameAsync(); + + // Read values + for (int i = 0; i < recorders.Count; i++) + { + var recorder = recorders[i]; + string name = counterNames[i]; + data[name] = recorder.Valid ? recorder.CurrentValueAsDouble : 0.0; + data[name + "_valid"] = recorder.Valid; + data[name + "_unit"] = recorder.Valid ? recorder.UnitType.ToString() : "Unknown"; + } + } + finally { - var recorder = recorders[i]; - string name = counterNames[i]; - data[name] = recorder.Valid ? recorder.CurrentValueAsDouble : 0.0; - data[name + "_valid"] = recorder.Valid; - data[name + "_unit"] = recorder.Valid ? recorder.UnitType.ToString() : "Unknown"; - recorder.Dispose(); + foreach (var recorder in recorders) + recorder.Dispose(); } return new SuccessResponse($"Captured {counterNames.Count} counter(s) from '{categoryName}'.", new diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs index b6b031686..b4094ee65 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs @@ -30,7 +30,7 @@ static FrameDebuggerOps() null, new[] { typeof(int) }, null) ?? UtilType.GetMethod("GetFrameEventData", BindingFlags.Public | BindingFlags.Static); } - ReflectionAvailable = UtilType != null && EventCountProp != null; + ReflectionAvailable = UtilType != null && EventCountProp != null && EnableMethod != null; } catch { @@ -50,7 +50,7 @@ internal static object Enable(JObject @params) { if (EnableMethod != null) { - EnableMethod.Invoke(null, new object[] { true, 0 }); + InvokeSetEnabled(true); } } catch (Exception ex) @@ -92,7 +92,7 @@ internal static object Disable(JObject @params) { if (EnableMethod != null) { - EnableMethod.Invoke(null, new object[] { false, 0 }); + InvokeSetEnabled(false); } } catch (Exception ex) @@ -193,6 +193,17 @@ internal static object GetEvents(JObject @params) return new SuccessResponse($"Frame Debugger events {cursor}-{end - 1} of {totalEvents}.", result); } + private static void InvokeSetEnabled(bool value) + { + int paramCount = EnableMethod.GetParameters().Length; + if (paramCount == 2) + EnableMethod.Invoke(null, new object[] { value, 0 }); + else if (paramCount == 1) + EnableMethod.Invoke(null, new object[] { value }); + else + throw new InvalidOperationException($"SetEnabled has unexpected {paramCount} parameters."); + } + private static void TryAddField(Type type, object obj, string fieldName, Dictionary dict) { try diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs index f67881fbe..898cecf5b 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs @@ -35,7 +35,7 @@ internal static async Task TakeSnapshotAsync(JObject @params) try { var takeMethod = MemoryProfilerType.GetMethod("TakeSnapshot", - new[] { typeof(string), typeof(Action), typeof(Action), typeof(uint) }); + new[] { typeof(string), typeof(Action), typeof(Action), typeof(uint) }); if (takeMethod == null) { diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs index f489c525f..4d11faa80 100644 --- a/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs @@ -32,9 +32,9 @@ internal static object Start(JObject @params) return new SuccessResponse("Profiler started.", new { enabled = Profiler.enabled, - recording, - log_file = recording ? Profiler.logFile : null, - allocation_callstacks = enableCallstacks, + recording = Profiler.enableBinaryLog, + log_file = Profiler.enableBinaryLog ? Profiler.logFile : null, + allocation_callstacks = Profiler.enableAllocationCallstacks, }); } @@ -84,6 +84,8 @@ internal static object SetAreas(JObject @params) if (!Enum.TryParse(prop.Name, true, out var area)) return new ErrorResponse($"Unknown area '{prop.Name}'. Valid: {string.Join(", ", AreaNames)}"); + if (prop.Value.Type != JTokenType.Boolean) + return new ErrorResponse($"Area '{prop.Name}' value must be a boolean (true/false), got: {prop.Value}"); bool enabled = prop.Value.ToObject(); Profiler.SetAreaEnabled(area, enabled); updated[prop.Name] = enabled;