-
Notifications
You must be signed in to change notification settings - Fork 803
feat: add manage_profiler and reserialize_assets tools #827
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: beta
Are you sure you want to change the base?
Changes from all commits
193e8f9
bfde901
a541994
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| using System.Collections.Generic; | ||
| using MCPForUnity.Editor.Helpers; | ||
| using Newtonsoft.Json.Linq; | ||
| using UnityEditor.Profiling; | ||
| using UnityEditorInternal; | ||
| using UnityEngine; | ||
|
|
||
| namespace MCPForUnity.Editor.Tools | ||
| { | ||
| /// <summary> | ||
| /// Reads Unity Profiler frame data and controls profiler state. | ||
| /// Uses ProfilerDriver to access the same data visible in the Profiler window. | ||
| /// </summary> | ||
| [McpForUnityTool("manage_profiler", AutoRegister = false)] | ||
| public static class ManageProfiler | ||
| { | ||
| /// <summary> | ||
| /// Main handler for profiler actions. | ||
| /// </summary> | ||
| public static object HandleCommand(JObject @params) | ||
| { | ||
| if (@params == null) | ||
| { | ||
| return new ErrorResponse("Parameters cannot be null."); | ||
| } | ||
|
|
||
| var p = new ToolParams(@params); | ||
|
|
||
| var actionResult = p.GetRequired("action"); | ||
| if (!actionResult.IsSuccess) | ||
| { | ||
| return new ErrorResponse(actionResult.ErrorMessage); | ||
| } | ||
| string action = actionResult.Value.ToLowerInvariant(); | ||
|
|
||
| switch (action) | ||
| { | ||
| case "read_frames": | ||
| return ReadFrames(p); | ||
| case "enable": | ||
| return EnableProfiler(); | ||
| case "disable": | ||
| return DisableProfiler(); | ||
| case "status": | ||
| return GetStatus(); | ||
| case "clear": | ||
| return ClearFrames(); | ||
| default: | ||
| return new ErrorResponse($"Unknown action: '{action}'. Valid actions: read_frames, enable, disable, status, clear."); | ||
| } | ||
| } | ||
|
|
||
| private static object ReadFrames(ToolParams p) | ||
| { | ||
| if (ProfilerDriver.enabled == false) | ||
| { | ||
| return new ErrorResponse("Profiler is not enabled. Use action='enable' first or open the Profiler window."); | ||
| } | ||
|
|
||
| int lastFrame = ProfilerDriver.lastFrameIndex; | ||
| int firstFrame = ProfilerDriver.firstFrameIndex; | ||
| if (lastFrame < 0 || firstFrame < 0) | ||
| { | ||
| return new ErrorResponse("No profiler frames available."); | ||
| } | ||
|
|
||
| int frameCount = p.GetInt("frameCount", 1) ?? 1; | ||
| int threadIndex = p.GetInt("thread", 0) ?? 0; | ||
| string filter = p.Get("filter") ?? ""; | ||
| float minMs = p.GetFloat("minMs", 0.01f) ?? 0.01f; | ||
|
|
||
| frameCount = System.Math.Min(frameCount, lastFrame - firstFrame + 1); | ||
|
|
||
| var frames = new List<object>(); | ||
| for (int f = 0; f < frameCount; f++) | ||
| { | ||
| int frameIndex = lastFrame - f; | ||
| using var frameData = ProfilerDriver.GetRawFrameDataView(frameIndex, threadIndex); | ||
| if (frameData == null || frameData.valid == false) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| var collected = new List<(string name, float ms, int childCount)>(); | ||
| for (int i = 0; i < frameData.sampleCount; i++) | ||
| { | ||
| float timeMs = frameData.GetSampleTimeMs(i); | ||
| if (timeMs < minMs) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| string name = frameData.GetSampleName(i); | ||
| if (string.IsNullOrEmpty(filter) == false && | ||
| name.IndexOf(filter, System.StringComparison.OrdinalIgnoreCase) < 0) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| collected.Add((name, timeMs, frameData.GetSampleChildrenCount(i))); | ||
| } | ||
|
|
||
| // Sort by time descending | ||
| collected.Sort((a, b) => b.ms.CompareTo(a.ms)); | ||
|
|
||
| var samples = new List<object>(collected.Count); | ||
| foreach (var s in collected) | ||
| { | ||
| samples.Add(new { s.name, s.ms, formatted = FormatTime(s.ms), s.childCount }); | ||
| } | ||
|
|
||
| frames.Add(new | ||
| { | ||
| frameIndex, | ||
| threadIndex, | ||
| sampleCount = frameData.sampleCount, | ||
| samples | ||
| }); | ||
| } | ||
|
|
||
| return new SuccessResponse($"{frames.Count} frames read", new { frames }); | ||
| } | ||
|
|
||
| private static object EnableProfiler() | ||
| { | ||
| UnityEngine.Profiling.Profiler.enabled = true; | ||
| ProfilerDriver.enabled = true; | ||
| return new SuccessResponse("Profiler enabled."); | ||
| } | ||
|
|
||
| private static object DisableProfiler() | ||
| { | ||
| ProfilerDriver.enabled = false; | ||
| UnityEngine.Profiling.Profiler.enabled = false; | ||
| return new SuccessResponse("Profiler disabled."); | ||
| } | ||
|
|
||
| private static object GetStatus() | ||
| { | ||
| int firstFrame = ProfilerDriver.firstFrameIndex; | ||
| int lastFrame = ProfilerDriver.lastFrameIndex; | ||
| int frameCount = lastFrame >= firstFrame ? lastFrame - firstFrame + 1 : 0; | ||
|
|
||
| return new SuccessResponse("Profiler status", new | ||
| { | ||
| enabled = ProfilerDriver.enabled, | ||
| firstFrame, | ||
| lastFrame, | ||
| frameCount, | ||
| isPlaying = Application.isPlaying | ||
| }); | ||
| } | ||
|
|
||
| private static object ClearFrames() | ||
| { | ||
| ProfilerDriver.ClearAllFrames(); | ||
| return new SuccessResponse("All profiler frames cleared."); | ||
| } | ||
|
|
||
| private static string FormatTime(float ms) | ||
| { | ||
| if (ms >= 1f) return $"{ms:F2}ms"; | ||
| if (ms >= 0.001f) return $"{ms * 1000f:F2}us"; | ||
| return $"{ms * 1_000_000f:F0}ns"; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| using System.Collections.Generic; | ||
| using MCPForUnity.Editor.Helpers; | ||
| using Newtonsoft.Json.Linq; | ||
| using UnityEditor; | ||
|
|
||
| namespace MCPForUnity.Editor.Tools | ||
| { | ||
| /// <summary> | ||
| /// Forces Unity to reserialize assets, updating them to the current serialization format. | ||
| /// Useful after Unity version upgrades, script changes that affect serialized data, | ||
| /// or when assets need their .meta files regenerated. | ||
| /// </summary> | ||
| [McpForUnityTool("reserialize_assets", AutoRegister = false)] | ||
| public static class ReserializeAssets | ||
| { | ||
| /// <summary> | ||
| /// Main handler for asset reserialization. | ||
| /// </summary> | ||
| public static object HandleCommand(JObject @params) | ||
| { | ||
| if (@params == null) | ||
| { | ||
| return new ErrorResponse("Parameters cannot be null."); | ||
| } | ||
|
|
||
| var p = new ToolParams(@params); | ||
|
|
||
| // Support both single path and array of paths | ||
| string singlePath = p.Get("path"); | ||
| JToken pathsToken = @params["paths"]; | ||
|
|
||
| var paths = new List<string>(); | ||
|
|
||
| if (pathsToken != null && pathsToken.Type == JTokenType.Array) | ||
| { | ||
| foreach (var item in pathsToken) | ||
| { | ||
| string val = item.ToString(); | ||
| if (!string.IsNullOrEmpty(val)) | ||
|
Comment on lines
+38
to
+39
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: Whitespace-only asset paths slip through the current validation. Because |
||
| { | ||
| paths.Add(val); | ||
| } | ||
| } | ||
| } | ||
| else if (!string.IsNullOrEmpty(singlePath)) | ||
| { | ||
| paths.Add(singlePath); | ||
| } | ||
|
|
||
| if (paths.Count == 0) | ||
| { | ||
| return new ErrorResponse("'path' (string) or 'paths' (array of strings) parameter required."); | ||
| } | ||
|
|
||
| AssetDatabase.ForceReserializeAssets(paths); | ||
|
|
||
| return new SuccessResponse( | ||
| $"Reserialized {paths.Count} asset(s).", | ||
| new { paths = paths.ToArray() } | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,81 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import Annotated, Any, Literal | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from fastmcp import Context | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from mcp.types import ToolAnnotations | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from services.registry import mcp_for_unity_tool | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from services.tools import get_unity_instance_from_context | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from transport.unity_transport import send_with_unity_instance | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from transport.legacy.unity_connection import async_send_command_with_retry | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @mcp_for_unity_tool( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description="Reads Unity Profiler frame data and controls profiler state. " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Uses ProfilerDriver to access the same data visible in the Profiler window. " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Read-only actions: read_frames, status. " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Modifying actions: enable, disable, clear.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| annotations=ToolAnnotations( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| title="Manage Profiler", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async def manage_profiler( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx: Context, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| action: Annotated[ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Literal["read_frames", "enable", "disable", "status", "clear"], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Action to perform. " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "'read_frames' reads profiler frame data (same as Profiler window). " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "'enable' starts profiler recording. " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "'disable' stops profiler recording. " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "'status' returns current profiler state. " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "'clear' clears all recorded frames." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| frame_count: Annotated[ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| int | str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Number of recent frames to read (default: 1). Only used with 'read_frames'." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | None = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| thread: Annotated[ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| int | str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Thread index to read (default: 0). " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "0=Main Thread (game logic, ECS, physics, animation), " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "1=Render Thread (GPU commands, shader), " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "2+=Job Worker Threads (Burst/Jobs parallel work). " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Only used with 'read_frames'." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | None = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| filter: Annotated[ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Filter samples by name (case-insensitive substring match). Only used with 'read_frames'." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | None = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| min_ms: Annotated[ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| float | str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Minimum milliseconds to include a sample (default: 0.01). Only used with 'read_frames'." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | None = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> dict[str, Any]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| unity_instance = get_unity_instance_from_context(ctx) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| params: dict[str, Any] = {"action": action} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if action == "read_frames": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if frame_count is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| params["frameCount"] = int(frame_count) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if thread is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| params["thread"] = int(thread) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if filter is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| params["filter"] = filter | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if min_ms is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| params["minMs"] = float(min_ms) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+58
to
+66
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Direct If
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| response = await send_with_unity_instance( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async_send_command_with_retry, unity_instance, "manage_profiler", params | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if isinstance(response, dict) and response.get("success"): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "success": True, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "message": response.get("message", "Profiler operation successful."), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "data": response.get("data"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return response if isinstance(response, dict) else {"success": False, "message": str(response)} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return {"success": False, "message": f"Python error managing profiler: {str(e)}"} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| from typing import Annotated, Any | ||
|
|
||
| from fastmcp import Context | ||
| from mcp.types import ToolAnnotations | ||
|
|
||
| from services.registry import mcp_for_unity_tool | ||
| from services.tools import get_unity_instance_from_context | ||
| from transport.unity_transport import send_with_unity_instance | ||
| from transport.legacy.unity_connection import async_send_command_with_retry | ||
|
|
||
|
|
||
| @mcp_for_unity_tool( | ||
| description="Forces Unity to reserialize assets, updating them to the current serialization format. " | ||
| "Useful after Unity version upgrades, script changes that affect serialized data, " | ||
| "or when assets need their .meta files regenerated. " | ||
| "Provide either 'path' for a single asset or 'paths' for multiple assets.", | ||
| annotations=ToolAnnotations( | ||
| title="Reserialize Assets", | ||
| ), | ||
| ) | ||
| async def reserialize_assets( | ||
| ctx: Context, | ||
| path: Annotated[ | ||
| str, | ||
| "Single asset path to reserialize (e.g., 'Assets/Prefabs/Player.prefab')." | ||
| ] | None = None, | ||
| paths: Annotated[ | ||
| list[str], | ||
| "Array of asset paths to reserialize." | ||
|
Comment on lines
+27
to
+29
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Empty If both |
||
| ] | None = None, | ||
| ) -> dict[str, Any]: | ||
| unity_instance = get_unity_instance_from_context(ctx) | ||
|
|
||
| try: | ||
| params: dict[str, Any] = {} | ||
|
|
||
| if paths is not None: | ||
| params["paths"] = paths | ||
| elif path is not None: | ||
| params["path"] = path | ||
| else: | ||
| return {"success": False, "message": "'path' or 'paths' parameter required."} | ||
|
|
||
| response = await send_with_unity_instance( | ||
| async_send_command_with_retry, unity_instance, "reserialize_assets", params | ||
| ) | ||
|
|
||
| if isinstance(response, dict) and response.get("success"): | ||
| return { | ||
| "success": True, | ||
| "message": response.get("message", "Assets reserialized."), | ||
| "data": response.get("data"), | ||
| } | ||
| return response if isinstance(response, dict) else {"success": False, "message": str(response)} | ||
|
|
||
| except Exception as e: | ||
| return {"success": False, "message": f"Python error reserializing assets: {str(e)}"} | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion (performance): Potentially large sample collections could benefit from a cap or top-N selection.
On frames with many samples and a low
minMs,collectedcan become very large and then be fully serialized and returned. Consider limiting samples per frame (e.g., keep only the top N byms) or adding amaxSamplesoption to avoid excessively large responses when profiling heavy frames.Suggested implementation:
using System.Linq;is present at the top ofManageProfiler.csso thatOrderByDescending,Take, andToListcompile.const int maxSamplesPerFrame = 200;with a parameter or configuration value (e.g., a method argument or a serialized setting) and thread that value through where this method is called.