Skip to content

Commit e2c30b0

Browse files
authored
Merge pull request #968 from CoplayDev/feat/open-prefab-stage
feat: add open_prefab_stage action to manage_editor
2 parents c1f0260 + fb97d6c commit e2c30b0

7 files changed

Lines changed: 336 additions & 6 deletions

File tree

MCPForUnity/Editor/Tools/ManageEditor.cs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using UnityEditor;
66
using UnityEditor.SceneManagement;
77
using UnityEditorInternal; // Required for tag management
8+
using UnityEngine;
89

910
namespace MCPForUnity.Editor.Tools
1011
{
@@ -46,6 +47,7 @@ public static object HandleCommand(JObject @params)
4647
// Parameters for specific actions
4748
string tagName = p.Get("tagName");
4849
string layerName = p.Get("layerName");
50+
string prefabPath = p.Get("prefabPath") ?? p.Get("path");
4951

5052
// Route action
5153
switch (action)
@@ -136,6 +138,8 @@ public static object HandleCommand(JObject @params)
136138
// return SetQualityLevel(@params["qualityLevel"]);
137139

138140
// Prefab Stage
141+
case "open_prefab_stage":
142+
return OpenPrefabStage(prefabPath);
139143
case "close_prefab_stage":
140144
return ClosePrefabStage();
141145

@@ -176,7 +180,7 @@ public static object HandleCommand(JObject @params)
176180

177181
default:
178182
return new ErrorResponse(
179-
$"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, close_prefab_stage, deploy_package, restore_package, undo, redo. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool."
183+
$"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, open_prefab_stage, close_prefab_stage, deploy_package, restore_package, undo, redo. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool."
180184
);
181185
}
182186
}
@@ -398,6 +402,64 @@ private static object RemoveLayer(string layerName)
398402

399403
// --- Prefab Stage Methods ---
400404

405+
private static object OpenPrefabStage(string requestedPath)
406+
{
407+
if (string.IsNullOrWhiteSpace(requestedPath))
408+
{
409+
return new ErrorResponse("'prefabPath' parameter is required for open_prefab_stage.");
410+
}
411+
412+
string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath);
413+
if (sanitizedPath == null)
414+
{
415+
return new ErrorResponse($"Invalid prefab path (path traversal detected): '{requestedPath}'.");
416+
}
417+
418+
if (!sanitizedPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
419+
{
420+
return new ErrorResponse($"Prefab path must be within the Assets folder. Got: '{sanitizedPath}'.");
421+
}
422+
423+
if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
424+
{
425+
return new ErrorResponse($"Prefab path must end with '.prefab'. Got: '{sanitizedPath}'.");
426+
}
427+
428+
try
429+
{
430+
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(sanitizedPath);
431+
if (prefabAsset == null)
432+
{
433+
return new ErrorResponse($"Prefab asset not found at '{sanitizedPath}'.");
434+
}
435+
436+
var prefabStage = PrefabStageUtility.OpenPrefab(sanitizedPath);
437+
bool enteredStage = prefabStage != null
438+
&& string.Equals(prefabStage.assetPath, sanitizedPath, StringComparison.OrdinalIgnoreCase)
439+
&& prefabStage.prefabContentsRoot != null;
440+
441+
if (!enteredStage)
442+
{
443+
return new ErrorResponse($"Failed to open prefab stage for '{sanitizedPath}'. PrefabStageUtility.OpenPrefab did not enter the requested prefab stage.");
444+
}
445+
446+
return new SuccessResponse(
447+
$"Opened prefab stage for '{sanitizedPath}'.",
448+
new
449+
{
450+
prefabPath = sanitizedPath,
451+
openedPrefabPath = prefabStage.assetPath,
452+
rootName = prefabStage.prefabContentsRoot.name,
453+
enteredPrefabStage = enteredStage
454+
}
455+
);
456+
}
457+
catch (Exception e)
458+
{
459+
return new ErrorResponse($"Error opening prefab stage: {e.Message}");
460+
}
461+
}
462+
401463
private static object ClosePrefabStage()
402464
{
403465
try

Server/src/services/resources/prefab.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ async def get_prefab_api_docs(_ctx: Context) -> MCPResponse:
5757
"workflow": [
5858
"1. Use manage_asset action=search filterType=Prefab to find prefabs",
5959
"2. Use the asset path to access detailed data via resources below",
60-
"3. Use manage_prefabs tool for prefab stage operations (open, save, close)"
60+
"3. Use manage_editor action=open_prefab_stage / close_prefab_stage for prefab editing UI transitions"
6161
],
6262
"path_encoding": {
6363
"note": "Prefab paths must be URL-encoded when used in resource URIs",
@@ -80,7 +80,8 @@ async def get_prefab_api_docs(_ctx: Context) -> MCPResponse:
8080
}
8181
},
8282
"related_tools": {
83-
"manage_prefabs": "Open/close prefab stages, save changes, create prefabs from GameObjects",
83+
"manage_editor": "Open/close prefab stages in the Unity Editor UI",
84+
"manage_prefabs": "Headless prefab inspection and modification without opening prefab stages",
8485
"manage_asset": "Search for prefab assets, get asset info",
8586
"manage_gameobject": "Modify GameObjects in open prefab stage",
8687
"manage_components": "Add/remove/modify components on prefab GameObjects"

Server/src/services/tools/manage_editor.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,24 @@
1010
from transport.legacy.unity_connection import async_send_command_with_retry
1111

1212
@mcp_for_unity_tool(
13-
description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, close_prefab_stage, deploy_package, restore_package, undo, redo. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name.",
13+
description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, open_prefab_stage, close_prefab_stage, deploy_package, restore_package, undo, redo. open_prefab_stage opens a prefab asset in Unity's prefab editing mode. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name.",
1414
annotations=ToolAnnotations(
1515
title="Manage Editor",
1616
),
1717
)
1818
async def manage_editor(
1919
ctx: Context,
20-
action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "close_prefab_stage", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. close_prefab_stage exits prefab editing mode and returns to the main scene stage. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo."],
20+
action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "open_prefab_stage", "close_prefab_stage", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. open_prefab_stage opens a prefab asset in prefab editing mode; close_prefab_stage exits prefab editing mode and returns to the main scene stage. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo."],
2121
tool_name: Annotated[str,
2222
"Tool name when setting active tool"] | None = None,
2323
tag_name: Annotated[str,
2424
"Tag name when adding and removing tags"] | None = None,
2525
layer_name: Annotated[str,
2626
"Layer name when adding and removing layers"] | None = None,
27+
prefab_path: Annotated[str,
28+
"Prefab asset path when opening a prefab stage (e.g. Assets/Prefabs/MyPrefab.prefab)."] | None = None,
29+
path: Annotated[str,
30+
"Compatibility alias for prefab_path when opening a prefab stage."] | None = None,
2731
) -> dict[str, Any]:
2832
# Get active instance from request state (injected by middleware)
2933
unity_instance = await get_unity_instance_from_context(ctx)
@@ -36,13 +40,24 @@ async def manage_editor(
3640
if action == "telemetry_ping":
3741
record_tool_usage("diagnostic_ping", True, 1.0, None)
3842
return {"success": True, "message": "telemetry ping queued"}
43+
44+
if prefab_path is not None and path is not None and prefab_path != path:
45+
return {
46+
"success": False,
47+
"message": "Provide only one of prefab_path or path, or ensure both values match.",
48+
}
49+
3950
# Prepare parameters, removing None values
4051
params = {
4152
"action": action,
4253
"toolName": tool_name,
4354
"tagName": tag_name,
4455
"layerName": layer_name,
4556
}
57+
if prefab_path is not None:
58+
params["prefabPath"] = prefab_path
59+
elif path is not None:
60+
params["path"] = path
4661
params = {k: v for k, v in params.items() if v is not None}
4762

4863
# Send command using centralized retry helper with instance routing

Server/tests/test_manage_editor.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Tests for manage_editor tool."""
22
import asyncio
3+
import inspect
34
from types import SimpleNamespace
45
from unittest.mock import AsyncMock
56

67
import pytest
78

89
from services.tools.manage_editor import manage_editor
10+
import services.tools.manage_editor as manage_editor_mod
11+
from services.registry import get_registered_tools
912

1013
# ── Fixture ──────────────────────────────────────────────────────────
1114

@@ -52,7 +55,7 @@ def test_redo_forwards_to_unity(mock_unity):
5255
UNITY_FORWARDED_ACTIONS = [
5356
"play", "pause", "stop", "set_active_tool",
5457
"add_tag", "remove_tag", "add_layer", "remove_layer",
55-
"close_prefab_stage", "deploy_package", "restore_package",
58+
"open_prefab_stage", "close_prefab_stage", "deploy_package", "restore_package",
5659
"undo", "redo",
5760
]
5861

@@ -90,3 +93,69 @@ def test_undo_omits_none_params(mock_unity):
9093
assert "toolName" not in params
9194
assert "tagName" not in params
9295
assert "layerName" not in params
96+
97+
98+
# ── open_prefab_stage ────────────────────────────────────────────────
99+
100+
101+
def test_manage_editor_prefab_path_parameters_exist():
102+
"""open_prefab_stage should expose prefab_path plus path alias parameters."""
103+
sig = inspect.signature(manage_editor_mod.manage_editor)
104+
assert "prefab_path" in sig.parameters
105+
assert "path" in sig.parameters
106+
assert sig.parameters["prefab_path"].default is None
107+
assert sig.parameters["path"].default is None
108+
109+
110+
def test_manage_editor_description_mentions_open_prefab_stage():
111+
"""The tool description should advertise the new prefab stage action."""
112+
editor_tool = next(
113+
(t for t in get_registered_tools() if t["name"] == "manage_editor"), None
114+
)
115+
assert editor_tool is not None
116+
desc = editor_tool.get("description") or editor_tool.get("kwargs", {}).get("description", "")
117+
assert "open_prefab_stage" in desc
118+
119+
120+
def test_open_prefab_stage_forwards_prefab_path(mock_unity):
121+
"""prefab_path should map to Unity's prefabPath parameter."""
122+
result = asyncio.run(
123+
manage_editor(
124+
SimpleNamespace(),
125+
action="open_prefab_stage",
126+
prefab_path="Assets/Prefabs/Test.prefab",
127+
)
128+
)
129+
assert result["success"] is True
130+
assert mock_unity["params"]["action"] == "open_prefab_stage"
131+
assert mock_unity["params"]["prefabPath"] == "Assets/Prefabs/Test.prefab"
132+
assert "path" not in mock_unity["params"]
133+
134+
135+
def test_open_prefab_stage_accepts_path_alias(mock_unity):
136+
"""path should remain available as a compatibility alias."""
137+
result = asyncio.run(
138+
manage_editor(
139+
SimpleNamespace(),
140+
action="open_prefab_stage",
141+
path="Assets/Prefabs/Alias.prefab",
142+
)
143+
)
144+
assert result["success"] is True
145+
assert mock_unity["params"]["action"] == "open_prefab_stage"
146+
assert mock_unity["params"]["path"] == "Assets/Prefabs/Alias.prefab"
147+
assert "prefabPath" not in mock_unity["params"]
148+
149+
150+
def test_open_prefab_stage_rejects_conflicting_path_inputs(mock_unity):
151+
"""Conflicting aliases should fail fast before sending a Unity command."""
152+
result = asyncio.run(
153+
manage_editor(
154+
SimpleNamespace(),
155+
action="open_prefab_stage",
156+
prefab_path="Assets/Prefabs/Primary.prefab",
157+
path="Assets/Prefabs/Alias.prefab",
158+
)
159+
)
160+
assert result["success"] is False
161+
assert "Provide only one of prefab_path or path" in result.get("message", "")

0 commit comments

Comments
 (0)