Skip to content
Closed
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
49 changes: 49 additions & 0 deletions MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs
Original file line number Diff line number Diff line change
@@ -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}");
}
}
}
}
25 changes: 25 additions & 0 deletions MCPForUnity/Editor/Tools/Profiler/Operations/AnimationTimingOps.cs
Original file line number Diff line number Diff line change
@@ -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<string, object>();

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
};
}
}
}
36 changes: 36 additions & 0 deletions MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs
Original file line number Diff line number Diff line change
@@ -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 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<string, object>();

foreach (var (counterName, valueKey, validKey) in COUNTER_MAP)
{
using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Internal, counterName);
data[valueKey] = recorder.Valid ? recorder.CurrentValue / 1e6 : 0.0;
data[validKey] = recorder.Valid;
}

return new
{
success = true,
message = "Frame timing captured.",
data
};
}
}
}
33 changes: 33 additions & 0 deletions MCPForUnity/Editor/Tools/Profiler/Operations/GCAllocOps.cs
Original file line number Diff line number Diff line change
@@ -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<string, object>();

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
};
}
}
}
34 changes: 34 additions & 0 deletions MCPForUnity/Editor/Tools/Profiler/Operations/PhysicsTimingOps.cs
Original file line number Diff line number Diff line change
@@ -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 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<string, object>();

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
};
}
}
}
35 changes: 35 additions & 0 deletions MCPForUnity/Editor/Tools/Profiler/Operations/ScriptTimingOps.cs
Original file line number Diff line number Diff line change
@@ -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 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<string, object>();

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
};
}
}
}
54 changes: 54 additions & 0 deletions Server/src/cli/commands/profiler.py
Original file line number Diff line number Diff line change
@@ -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.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))


@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))
57 changes: 57 additions & 0 deletions Server/src/services/tools/manage_profiler.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

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