Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions MCPForUnity/Editor/Tools/ManageProfiler.cs
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);
Comment on lines +84 to +93
Copy link
Contributor

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, collected can become very large and then be fully serialized and returned. Consider limiting samples per frame (e.g., keep only the top N by ms) or adding a maxSamples option to avoid excessively large responses when profiling heavy frames.

Suggested implementation:

                    continue;
                }

                // Collect samples that pass filters, then cap to the top-N by time to avoid excessively large responses.
                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)));
                }

                // Limit the number of samples per frame to avoid huge payloads on heavy frames.
                const int maxSamplesPerFrame = 200;
                if (collected.Count > maxSamplesPerFrame)
                {
                    collected = collected
                        .OrderByDescending(s => s.ms)
                        .Take(maxSamplesPerFrame)
                        .ToList();
                }
  1. Ensure using System.Linq; is present at the top of ManageProfiler.cs so that OrderByDescending, Take, and ToList compile.
  2. If you want this limit to be configurable rather than hard-coded, replace the 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.

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";
}
}
}
63 changes: 63 additions & 0 deletions MCPForUnity/Editor/Tools/ReserializeAssets.cs
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Whitespace-only asset paths slip through the current validation.

Because !string.IsNullOrEmpty(val) doesn’t filter out whitespace-only strings (e.g., " "), those values will still be added and passed to ForceReserializeAssets. Consider using string.IsNullOrWhiteSpace(val) and trimming before adding to paths to avoid malformed paths.

{
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() }
);
}
}
}
81 changes: 81 additions & 0 deletions Server/src/services/tools/manage_profiler.py
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Direct int/float casts give a generic error message for invalid numeric inputs.

If frame_count, thread, or min_ms are non-numeric strings, the int(...)/float(...) calls will raise ValueError and be caught by the broad handler, resulting in only the generic "Python error managing profiler" message. Consider catching ValueError around these casts and returning a field-specific error (e.g., "frame_count must be an integer"), while letting other exceptions fall through to the generic handler.

Suggested change
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)
if action == "read_frames":
if frame_count is not None:
try:
params["frameCount"] = int(frame_count)
except (TypeError, ValueError):
return {
"success": False,
"message": "frame_count must be an integer.",
}
if thread is not None:
try:
params["thread"] = int(thread)
except (TypeError, ValueError):
return {
"success": False,
"message": "thread must be an integer.",
}
if filter is not None:
params["filter"] = filter
if min_ms is not None:
try:
params["minMs"] = float(min_ms)
except (TypeError, ValueError):
return {
"success": False,
"message": "min_ms must be a number.",
}


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)}"}
57 changes: 57 additions & 0 deletions Server/src/services/tools/reserialize_assets.py
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Empty paths lists and precedence over path could be handled more explicitly.

If both path and paths are provided, paths silently take precedence and path is ignored, and an empty paths list is forwarded to Unity, which then returns "'path' or 'paths' parameter required". Consider validating that paths is non-empty before sending the command and raising a clear client-side error when both path and paths are set, instead of silently preferring one.

] | 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)}"}
Loading