Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0011a7a
feat: add manage_profiler tool for CPU timing, GC alloc, and animatio…
zaferdace Mar 26, 2026
92b4116
fix: correct ProfilerRecorder counter names in ScriptTiming and Physi…
zaferdace Mar 26, 2026
c0a2a2d
fix: update physics counter names in manage_profiler description
zaferdace Mar 26, 2026
42b020b
fix: address review feedback — correct counter names, CLI help text, …
zaferdace Mar 26, 2026
5dcecd3
refactor: use explicit valid keys in COUNTER_MAP tuples
zaferdace Mar 26, 2026
771fdf1
fix: use official Unity Frame Timing API counter names
zaferdace Mar 26, 2026
f4e98d8
feat(profiler): rewrite Python MCP tool with 14 actions across 4 groups
Scriptwonder Mar 29, 2026
7c21671
feat(profiler): async dispatcher + SessionOps, delete contributor Ops…
Scriptwonder Mar 29, 2026
09bfced
feat(profiler): add FrameTimingOps using FrameTimingManager (sync, 12…
Scriptwonder Mar 29, 2026
0fe485b
feat(profiler): add CounterOps with async 1-frame-wait recorder pattern
Scriptwonder Mar 29, 2026
97937c1
feat(profiler): add ObjectMemoryOps for per-object memory queries
Scriptwonder Mar 29, 2026
a5fabef
feat(profiler): add MemorySnapshotOps with take/list/compare via refl…
Scriptwonder Mar 29, 2026
4095937
feat(profiler): add FrameDebuggerOps with reflection-based event extr…
Scriptwonder Mar 29, 2026
1f0fb1d
feat(profiler): rewrite CLI with 14 subcommands, register in main
Scriptwonder Mar 29, 2026
25357a0
fix(profiler): address code review — error on unknown category, tight…
Scriptwonder Mar 29, 2026
fbaa7ce
chore: bump version to 9.6.3-beta.11
Scriptwonder Mar 29, 2026
fa64235
Revert "chore: bump version to 9.6.3-beta.11"
Scriptwonder Mar 29, 2026
c120fdd
fix(profiler): address AI review — try/finally disposal, safe reflect…
Scriptwonder Mar 29, 2026
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
243 changes: 204 additions & 39 deletions .claude/skills/unity-mcp-skill/references/tools-reference.md

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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 = "profiling")]
public static class ManageProfiler
{
public static async Task<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)
{
// 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_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: "
+ "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)
{
McpLog.Error($"[ManageProfiler] Action '{action}' failed: {ex}");
return new ErrorResponse($"Error in action '{action}': {ex.Message}");
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

ManageProfiler.HandleCommand() exception handling returns ErrorResponse without a stack trace payload, which is inconsistent with the pattern used by other tools (e.g., ManageBuild/ManageMaterial return new ErrorResponse(ex.Message, new { stackTrace = ex.StackTrace })). Including stackTrace here would improve debuggability and keep error shapes consistent across tools.

Suggested change
return new ErrorResponse($"Error in action '{action}': {ex.Message}");
return new ErrorResponse($"Error in action '{action}': {ex.Message}", new { stackTrace = ex.StackTrace });

Copilot uses AI. Check for mistakes.
}
}
}
}
141 changes: 141 additions & 0 deletions MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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<object> 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;
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);
if (counterNames.Count == 0)
return new SuccessResponse($"No counters found in category '{categoryName}'.", new
{
category = categoryName,
counters = new Dictionary<string, object>()
});

// Start recorders
var recorders = new List<ProfilerRecorder>();
foreach (string name in counterNames)
{
recorders.Add(ProfilerRecorder.StartNew(category, name));
}
Comment on lines +29 to +42
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs

Repository: CoplayDev/unity-mcp

Length of output: 6643


🌐 Web query:

ProfilerRecorder.StartNew exception throw behavior Unity documentation

💡 Result:

According to the official Unity documentation for ProfilerRecorder.StartNew (Unity 6.3 LTS and earlier versions), the method throws an exception if the capacity parameter is not greater than 0. The note states: "capacity parameter must be greater than 0, otherwise StartNew throws an exception." This is the primary documented exception-throwing behavior. No other specific exception conditions (e.g., invalid category, statName, or options) are explicitly mentioned in the API docs. Unity forum discussions suggest potential issues like passing negative capacity (e.g., -1 from targetFrameRate) can lead to memory allocator overflows or crashes, but these are not formally documented as exceptions from StartNew itself. Always ensure capacity > 0 when calling ProfilerRecorder.StartNew(ProfilerCategory category, string statName, int capacity, ProfilerRecorderOptions options) or similar overloads. Dispose recorders properly when done.

Citations:


🌐 Web query:

Unity ProfilerRecorder.StartNew(ProfilerCategory, string) exceptions invalid counter name

💡 Result:

There is no overload of ProfilerRecorder.StartNew(ProfilerCategory, string). The documented overloads require additional parameters: StartNew(ProfilerCategory category, string statName, int capacity, ProfilerRecorderOptions options) or StartNew(ProfilerMarker marker, int capacity, ProfilerRecorderOptions options). Calling a non-existent overload will result in a compile-time error, not a runtime exception for "invalid counter name". The official documentation explicitly states that StartNew throws an exception if capacity <= 0. Examples in the docs use the full 4-parameter overload, such as ProfilerRecorder.StartNew(ProfilerCategory.Memory, "System Used Memory") which implies default values for capacity and options in some contexts or C# overload resolution. If using the correct overload with an invalid statName (non-existent marker/counter in the category), the ProfilerRecorder.Valid property will be false, but no exception is documented for invalid names. Forum discussions indicate ProfilerRecorder may not work or return invalid for some statNames that don't exist. To check available stats, use ProfilerRecorderHandle.GetAvailable and ProfilerRecorderDescription.

Citations:


🏁 Script executed:

grep -n "GetCountersAsync" MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs

Repository: CoplayDev/unity-mcp

Length of output: 142


🏁 Script executed:

rg "ProfilerRecorder.StartNew" --type cs -B 2 -A 2

Repository: CoplayDev/unity-mcp

Length of output: 1059


🏁 Script executed:

cat -n MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs | head -50

Repository: CoplayDev/unity-mcp

Length of output: 2433


Wrap recorder lifetime in try/finally and validate explicit counter names.

The explicit counters path bypasses validation against the resolved category. Additionally, recorder disposal only happens in the happy path. If ProfilerRecorder.StartNew() throws or if WaitOneFrameAsync() or the read loop fails, created recorders are leaked. Use try/finally to guarantee disposal (as shown in RenderingStatsOps.cs with using var), and validate explicit counter names against available handles before starting recorders.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs` around lines 29 -
42, Validate any explicit counter names against the resolved category handles
before starting recorders (ensure names provided by the caller exist in the list
returned by GetRequestedCounters for the given categoryName) and then wrap
recorder creation and usage in a try/finally so any ProfilerRecorder instances
started with ProfilerRecorder.StartNew are always disposed; specifically, when
iterating counterNames to call ProfilerRecorder.StartNew, guard against StartNew
throwing by disposing any recorders created so far, perform the awaited work
(e.g., WaitOneFrameAsync and read loop) inside the try, and in the finally
iterate the recorders list to Stop/Dispose them (follow the same pattern used in
RenderingStatsOps.cs / using var).


var data = new Dictionary<string, object>();
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
{
foreach (var recorder in recorders)
recorder.Dispose();
}

return new SuccessResponse($"Captured {counterNames.Count} counter(s) from '{categoryName}'.", new
{
category = categoryName,
counters = data,
});
}

private static List<string> GetRequestedCounters(ToolParams p, ProfilerCategory category)
{
var explicitCounters = p.GetStringArray("counters");
if (explicitCounters != null && explicitCounters.Length > 0)
return explicitCounters.ToList();

var allHandles = new List<ProfilerRecorderHandle>();
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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

void Tick()
{
EditorApplication.update -= Tick;
tcs.TrySetResult(true);
}

EditorApplication.update += Tick;
try { EditorApplication.QueuePlayerLoopUpdate(); } catch { /* throttled editor */ }
return tcs.Task;
}

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;
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:
error = $"Unknown category '{name}'. Valid: {string.Join(", ", ValidCategories)}";
return null;
}
}
}
}
Loading