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
2 changes: 1 addition & 1 deletion .claude/skills/unity-mcp-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ uri="file:///full/path/to/file.cs"
| **Objects** | `manage_gameobject`, `manage_components` | Creating/modifying GameObjects |
| **Scripts** | `create_script`, `script_apply_edits`, `validate_script` | C# code management (auto-refreshes on create/edit) |
| **Assets** | `manage_asset`, `manage_prefabs` | Asset operations. **Prefab instantiation** is done via `manage_gameobject(action="create", prefab_path="...")`, not `manage_prefabs`. |
| **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control, package deployment (`deploy_package`/`restore_package`), undo/redo (`undo`/`redo` actions) |
| **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control; play startup (`wait_until`, `on_ready_code`) and status (`get_play_mode_startup`); package deployment (`deploy_package`/`restore_package`); undo/redo (`undo`/`redo` actions). Poll the startup job after a timeout; do not resubmit code. |
| **Testing** | `run_tests`, `get_test_job` | Unity Test Framework |
| **Batch** | `batch_execute` | Parallel/bulk operations |
| **Camera** | `manage_camera` | Camera management (Unity Camera + Cinemachine). **Tier 1** (always available): create, target, lens, priority, list, screenshot. **Tier 2** (requires `com.unity.cinemachine`): brain, body/aim/noise pipeline, extensions, blending, force/release. 7 presets: follow, third_person, freelook, dolly, static, top_down, side_scroller. Resource: `mcpforunity://scene/cameras`. Use `ping` to check Cinemachine availability. See [tools-reference.md](references/tools-reference.md#camera-tools). |
Expand Down
5 changes: 5 additions & 0 deletions .claude/skills/unity-mcp-skill/references/tools-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,9 @@ Control Unity Editor state, undo/redo. Prefab stages are `manage_prefabs` action

```python
manage_editor(action="play") # Enter play mode
manage_editor(action="play", wait_until="first_frame", wait_timeout=60)
response = manage_editor(action="play", on_ready_code="return UnityEngine.Time.frameCount;")
manage_editor(action="get_play_mode_startup", job_id=response["data"]["job_id"])
manage_editor(action="pause") # Pause play mode
manage_editor(action="stop") # Exit play mode

Expand All @@ -775,6 +778,8 @@ manage_editor(action="deploy_package") # Copy configured MCPForUnity source
manage_editor(action="restore_package") # Revert to pre-deployment backup
```

**Play startup:** `scene_loaded` waits for loading; `first_frame` also waits for `Start` and a completed frame. `on_ready_code` always waits for first-frame startup and runs once with `execute_code`'s compiler and safety checks, returning `data.on_ready_result`. Waits default to 30 seconds (1–300 supported). After a timeout or disconnected reply, poll the returned `job_id` on the same Unity instance; do not submit the code again. A timeout does not stop Play Mode. Pending code expires; code already executing may still finish. Jobs survive domain reloads without changing Enter Play Mode settings, with results retained for the latest 16 jobs in the current Editor session.

**Deploy workflow:** Set the source path in MCP for Unity Advanced Settings first. `deploy_package` copies the source into the project's package location, creates a backup, and triggers `AssetDatabase.Refresh`. Follow with `refresh_unity(wait_for_ready=True)` to wait for recompilation.

### execute_menu_item
Expand Down
208 changes: 208 additions & 0 deletions MCPForUnity/Editor/Services/PlayModeStartupService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Tools;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;

namespace MCPForUnity.Editor.Services
{
/// <summary>Tracks play startup across domain reloads without changing Enter Play Mode options.</summary>
[InitializeOnLoad]
internal static class PlayModeStartupService
{
private const string JobsKey = "MCPForUnity.PlayStartup.Jobs";
private const string RequestsKey = "MCPForUnity.PlayStartup.Requests";
private const string FrameKey = "MCPForUnity.PlayStartup.EnteredFrame";
private const int MaxJobs = 16;
private static readonly List<StartupJob> Jobs = LoadJobs();
private static readonly HashSet<string> Requests = LoadRequests();

private sealed class StartupJob
{
public string Id;
public string Status = "waiting";
public string WaitUntil;
public string Code;
public double StartedAt;
public double Timeout;
public double? FinishedAt;
public bool SceneLoaded;
public bool FirstFrameComplete;
public bool CodeAttempted;
public JObject CodeResult;
public string Error;
public string SceneName;
}

static PlayModeStartupService()
{
// The code may itself trigger a reload. A durable execution marker prevents
// replaying side effects when its original call/result did not survive.
foreach (var job in Jobs.Where(item => item.Status == "executing"))
Fail(job, "Startup code was interrupted by a domain reload and will not be repeated.");
EditorApplication.playModeStateChanged += OnPlayModeChanged;
EditorApplication.update += OnUpdate;
}

public static object Start(JObject args)
{
string waitUntil = args["waitUntil"]?.ToString();
string code = args["onReadyCode"]?.Type == JTokenType.Null ? null : args["onReadyCode"]?.ToString();
if (waitUntil != null && waitUntil != "scene_loaded" && waitUntil != "first_frame")
return new ErrorResponse("waitUntil must be 'scene_loaded' or 'first_frame'.");
if (code != null && (string.IsNullOrWhiteSpace(code) || code.Length > ExecuteCode.MaxCodeLength))
return new ErrorResponse($"onReadyCode must contain a C# body of at most {ExecuteCode.MaxCodeLength} characters.");
if (waitUntil == null && code == null)
{
if (args["waitTimeout"] != null) return new ErrorResponse("waitTimeout requires waitUntil or onReadyCode.");
if (EditorApplication.isPlaying) return new SuccessResponse("Already in play mode.");
EditorApplication.isPlaying = true;
return new SuccessResponse("Entered play mode.");
}
double timeout = args["waitTimeout"]?.ToObject<double>() ?? 30;
if (double.IsNaN(timeout) || double.IsInfinity(timeout) || timeout < 1 || timeout > 300)
return new ErrorResponse("waitTimeout must be between 1 and 300 seconds.");
string id = args["startupRequestId"]?.ToString() ?? Guid.NewGuid().ToString("N");
if (!Guid.TryParse(id, out var parsedId)) return new ErrorResponse("startupRequestId must be a UUID.");
id = parsedId.ToString("N");
var previous = Jobs.FirstOrDefault(job => job.Id == id);
if (previous != null) return Response(previous);
if (Requests.Contains(id)) return new ErrorResponse("This startup request's result has expired; its code will not be repeated.");
var active = Jobs.FirstOrDefault(job => job.Status == "waiting" || job.Status == "executing");
if (active != null)
return new ErrorResponse("A Play Mode startup job is already active. Poll its job_id before starting another.",
new { job_id = active.Id, status = active.Status });
if (EditorApplication.isPlaying && !EditorApplication.isPlayingOrWillChangePlaymode)
return new ErrorResponse("Play Mode is stopping; wait until it has stopped before starting a job.");

var current = new StartupJob { Id = id, WaitUntil = code == null ? waitUntil : "first_frame",
Code = code, StartedAt = EditorApplication.timeSinceStartup, Timeout = timeout };
while (Jobs.Count >= MaxJobs) Jobs.RemoveAt(0);
Jobs.Add(current);
Requests.Add(id);
Persist();
if (!EditorApplication.isPlaying && !EditorApplication.isPlayingOrWillChangePlaymode)
{
SessionState.SetInt(FrameKey, -1);
EditorApplication.isPlaying = true;
}
return Response(current);
}

public static object Get(string id)
{
if (string.IsNullOrWhiteSpace(id)) return new ErrorResponse("jobId is required for get_play_mode_startup.");
if (!Guid.TryParse(id, out var parsedId)) return new ErrorResponse("jobId must be a UUID.");
id = parsedId.ToString("N");
var job = Jobs.FirstOrDefault(item => item.Id == id);
return job == null ? new ErrorResponse("Play Mode startup job was not found in this Editor session.") : Response(job);
}

private static object Response(StartupJob job) => new SuccessResponse("Play Mode startup status.", new {
job_id = job.Id, status = job.Status, wait_until = job.WaitUntil,
scene_loaded = job.SceneLoaded, first_frame_complete = job.FirstFrameComplete,
scene_name = job.SceneName, code_attempted = job.CodeAttempted, on_ready_result = job.CodeResult,
error = job.Error, elapsed_seconds = (job.FinishedAt ?? EditorApplication.timeSinceStartup) - job.StartedAt,
unity_instance = ProjectIdentityUtility.GetProjectHash()
});

private static void OnPlayModeChanged(PlayModeStateChange change)
{
if (change == PlayModeStateChange.ExitingEditMode) SessionState.SetInt(FrameKey, -1);
if (change == PlayModeStateChange.EnteredPlayMode) SessionState.SetInt(FrameKey, Time.frameCount);
if (change == PlayModeStateChange.ExitingPlayMode || change == PlayModeStateChange.EnteredEditMode)
{
CancelWaiting();
}
if (change == PlayModeStateChange.EnteredEditMode) SessionState.SetInt(FrameKey, -1);
}

public static void CancelWaiting()
{
foreach (var job in Jobs.Where(item => item.Status == "waiting"))
Fail(job, "Play Mode stopped before startup completed.");
}

private static void OnUpdate()
{
var job = Jobs.FirstOrDefault(item => item.Status == "waiting");
if (job == null) return;
if (EditorApplication.timeSinceStartup - job.StartedAt >= job.Timeout)
{
Fail(job, "Timed out waiting for Play Mode startup. Play Mode has not been stopped.");
return;
}
int enteredFrame = SessionState.GetInt(FrameKey, -1);
var scene = SceneManager.GetActiveScene();
bool loaded = EditorApplication.isPlaying && Application.isPlaying && enteredFrame >= 0 && scene.isLoaded;
// Frame count increments at the start of Update. Waiting for a subsequent
// frame ensures a whole earlier frame (including Start) has completed.
bool firstFrame = loaded && (long)Time.frameCount - enteredFrame >= 2;
if (!loaded || (job.WaitUntil == "first_frame" && !firstFrame)) return;
job.SceneLoaded = true;
job.FirstFrameComplete = firstFrame;
job.SceneName = scene.name;
if (job.Code == null)
{
job.Status = "completed";
job.FinishedAt = EditorApplication.timeSinceStartup;
Persist();
return;
}
job.Status = "executing";
job.CodeAttempted = true;
Persist();
try
{
// Use the same compiler, safety checks, result format and history as execute_code.
var result = ExecuteCode.HandleCommand(new JObject { ["action"] = "execute", ["code"] = job.Code });
job.CodeResult = JObject.FromObject(result, UnityJsonSerializer.Instance);
job.Status = job.CodeResult.Value<bool>("success") ? "completed" : "failed";
job.Error = job.Status == "failed" ? job.CodeResult["error"]?.ToString() ?? "Startup code failed." : null;
}
catch (Exception)
{
job.Status = "failed";
job.Error = "Startup code failed; it will not be repeated.";
}
job.Code = null;
job.FinishedAt = EditorApplication.timeSinceStartup;
Persist();
}

private static void Fail(StartupJob job, string reason)
{
job.Status = "failed";
job.Error = reason;
job.Code = null;
job.FinishedAt = EditorApplication.timeSinceStartup;
Persist();
}

private static List<StartupJob> LoadJobs()
{
try { return JsonConvert.DeserializeObject<List<StartupJob>>(SessionState.GetString(JobsKey, "[]")) ?? new List<StartupJob>(); }
catch (JsonException) { return new List<StartupJob>(); }
}

private static HashSet<string> LoadRequests()
{
HashSet<string> requests;
try { requests = JsonConvert.DeserializeObject<HashSet<string>>(SessionState.GetString(RequestsKey, "[]")) ?? new HashSet<string>(); }
catch (JsonException) { requests = new HashSet<string>(); }
requests.UnionWith(Jobs.Select(job => job.Id));
return requests;
}

private static void Persist()
{
SessionState.SetString(JobsKey, JsonConvert.SerializeObject(Jobs));
SessionState.SetString(RequestsKey, JsonConvert.SerializeObject(Requests));
}
}
}
11 changes: 11 additions & 0 deletions MCPForUnity/Editor/Services/PlayModeStartupService.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion MCPForUnity/Editor/Tools/ExecuteCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace MCPForUnity.Editor.Tools
[McpForUnityTool("execute_code", AutoRegister = false, Group = "scripting_ext")]
public static class ExecuteCode
{
private const int MaxCodeLength = 50000;
internal const int MaxCodeLength = 50000;
private const int MaxHistoryEntries = 50;
private const int MaxHistoryCodePreview = 500;
internal const int WrapperLineOffset = 10;
Expand Down
14 changes: 6 additions & 8 deletions MCPForUnity/Editor/Tools/ManageEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,14 @@ public static object HandleCommand(JObject @params)
case "play":
try
{
if (!EditorApplication.isPlaying)
{
EditorApplication.isPlaying = true;
return new SuccessResponse("Entered play mode.");
}
return new SuccessResponse("Already in play mode.");
return PlayModeStartupService.Start(@params);
}
catch (Exception e)
{
return new ErrorResponse($"Error entering play mode: {e.Message}");
}
case "get_play_mode_startup":
return PlayModeStartupService.Get(@params["jobId"]?.ToString());
case "pause":
try
{
Expand All @@ -83,7 +80,8 @@ public static object HandleCommand(JObject @params)
case "stop":
try
{
if (EditorApplication.isPlaying)
PlayModeStartupService.CancelWaiting();
if (EditorApplication.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode)
{
EditorApplication.isPlaying = false;
return new SuccessResponse("Exited play mode.");
Expand Down Expand Up @@ -171,7 +169,7 @@ public static object HandleCommand(JObject @params)

default:
return new ErrorResponse(
$"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool."
$"Unknown action: '{action}'. Supported actions: play, get_play_mode_startup, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool."
);
}
}
Expand Down
30 changes: 27 additions & 3 deletions Server/src/cli/commands/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,40 @@ def editor():


@editor.command("play")
@click.option("--wait-until", type=click.Choice(["scene_loaded", "first_frame"]), help="Wait for a startup milestone; first_frame includes Start methods.")
@click.option("--on-ready-code", help="C# body executed once after first-frame startup.")
@click.option("--wait-timeout", type=click.FloatRange(min=1, max=300), default=None, help="Startup wait in seconds (1-300; default 30). Requires a startup option.")
@handle_unity_errors
def play():
"""Enter play mode."""
def play(wait_until: Optional[str], on_ready_code: Optional[str], wait_timeout: Optional[float]) -> None:
"""Enter play mode, optionally wait for startup and run a C# body once."""
config = get_config()
result = run_command("manage_editor", {"action": "play"}, config)
if wait_timeout is not None and wait_until is None and on_ready_code is None:
raise click.UsageError("--wait-timeout requires --wait-until or --on-ready-code.")
params: dict[str, Any] = {"action": "play"}
if wait_until is not None:
params["waitUntil"] = wait_until
if on_ready_code is not None:
params["onReadyCode"] = on_ready_code
if wait_until is not None or on_ready_code is not None:
params["waitTimeout"] = wait_timeout if wait_timeout is not None else 30
result = run_command("manage_editor", params, config, timeout=int(params["waitTimeout"]) + 5)
else:
result = run_command("manage_editor", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Entered play mode")


@editor.command("play-status")
@click.argument("job_id")
@handle_unity_errors
def play_status(job_id: str) -> None:
"""Read a startup job after a timeout or lost reply; does not rerun code."""
config = get_config()
result = run_command("manage_editor", {"action": "get_play_mode_startup", "jobId": job_id}, config)
click.echo(format_output(result, config.format))


@editor.command("pause")
@handle_unity_errors
def pause():
Expand Down
Loading
Loading