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
64 changes: 63 additions & 1 deletion MCPForUnity/Editor/Tools/ManageEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEditorInternal; // Required for tag management
using UnityEngine;

namespace MCPForUnity.Editor.Tools
{
Expand Down Expand Up @@ -46,6 +47,7 @@ public static object HandleCommand(JObject @params)
// Parameters for specific actions
string tagName = p.Get("tagName");
string layerName = p.Get("layerName");
string prefabPath = p.Get("prefabPath") ?? p.Get("path");

// Route action
switch (action)
Expand Down Expand Up @@ -136,6 +138,8 @@ public static object HandleCommand(JObject @params)
// return SetQualityLevel(@params["qualityLevel"]);

// Prefab Stage
case "open_prefab_stage":
return OpenPrefabStage(prefabPath);
case "close_prefab_stage":
return ClosePrefabStage();

Expand All @@ -147,7 +151,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, close_prefab_stage, deploy_package, restore_package. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool."
$"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. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool."
);
}
}
Expand Down Expand Up @@ -369,6 +373,64 @@ private static object RemoveLayer(string layerName)

// --- Prefab Stage Methods ---

private static object OpenPrefabStage(string requestedPath)
{
if (string.IsNullOrWhiteSpace(requestedPath))
{
return new ErrorResponse("'prefabPath' parameter is required for open_prefab_stage.");
}

string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath);
if (sanitizedPath == null)
{
return new ErrorResponse($"Invalid prefab path (path traversal detected): '{requestedPath}'.");
}

if (!sanitizedPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return new ErrorResponse($"Prefab path must be within the Assets folder. Got: '{sanitizedPath}'.");
}

if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
{
return new ErrorResponse($"Prefab path must end with '.prefab'. Got: '{sanitizedPath}'.");
}

try
{
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(sanitizedPath);
if (prefabAsset == null)
{
return new ErrorResponse($"Prefab asset not found at '{sanitizedPath}'.");
}

var prefabStage = PrefabStageUtility.OpenPrefab(sanitizedPath);
bool enteredStage = prefabStage != null
&& string.Equals(prefabStage.assetPath, sanitizedPath, StringComparison.OrdinalIgnoreCase)
&& prefabStage.prefabContentsRoot != null;

if (!enteredStage)
{
return new ErrorResponse($"Failed to open prefab stage for '{sanitizedPath}'. PrefabStageUtility.OpenPrefab did not enter the requested prefab stage.");
}

return new SuccessResponse(
$"Opened prefab stage for '{sanitizedPath}'.",
new
{
prefabPath = sanitizedPath,
openedPrefabPath = prefabStage.assetPath,
rootName = prefabStage.prefabContentsRoot.name,
enteredPrefabStage = enteredStage
}
);
}
catch (Exception e)
{
return new ErrorResponse($"Error opening prefab stage: {e.Message}");
}
}

private static object ClosePrefabStage()
{
try
Expand Down
5 changes: 3 additions & 2 deletions Server/src/services/resources/prefab.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ async def get_prefab_api_docs(_ctx: Context) -> MCPResponse:
"workflow": [
"1. Use manage_asset action=search filterType=Prefab to find prefabs",
"2. Use the asset path to access detailed data via resources below",
"3. Use manage_prefabs tool for prefab stage operations (open, save, close)"
"3. Use manage_editor action=open_prefab_stage / close_prefab_stage for prefab editing UI transitions"
],
"path_encoding": {
"note": "Prefab paths must be URL-encoded when used in resource URIs",
Expand All @@ -80,7 +80,8 @@ async def get_prefab_api_docs(_ctx: Context) -> MCPResponse:
}
},
"related_tools": {
"manage_prefabs": "Open/close prefab stages, save changes, create prefabs from GameObjects",
"manage_editor": "Open/close prefab stages in the Unity Editor UI",
"manage_prefabs": "Headless prefab inspection and modification without opening prefab stages",
"manage_asset": "Search for prefab assets, get asset info",
"manage_gameobject": "Modify GameObjects in open prefab stage",
"manage_components": "Add/remove/modify components on prefab GameObjects"
Expand Down
19 changes: 17 additions & 2 deletions Server/src/services/tools/manage_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,24 @@
from transport.legacy.unity_connection import async_send_command_with_retry

@mcp_for_unity_tool(
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. 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.",
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. 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.",
annotations=ToolAnnotations(
title="Manage Editor",
),
)
async def manage_editor(
ctx: Context,
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"], "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."],
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"], "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."],
tool_name: Annotated[str,
"Tool name when setting active tool"] | None = None,
tag_name: Annotated[str,
"Tag name when adding and removing tags"] | None = None,
layer_name: Annotated[str,
"Layer name when adding and removing layers"] | None = None,
prefab_path: Annotated[str,
"Prefab asset path when opening a prefab stage (e.g. Assets/Prefabs/MyPrefab.prefab)."] | None = None,
path: Annotated[str,
"Compatibility alias for prefab_path when opening a prefab stage."] | None = None,
) -> dict[str, Any]:
# Get active instance from request state (injected by middleware)
unity_instance = await get_unity_instance_from_context(ctx)
Expand All @@ -36,13 +40,24 @@ async def manage_editor(
if action == "telemetry_ping":
record_tool_usage("diagnostic_ping", True, 1.0, None)
return {"success": True, "message": "telemetry ping queued"}

if prefab_path is not None and path is not None and prefab_path != path:
return {
"success": False,
"message": "Provide only one of prefab_path or path, or ensure both values match.",
}

# Prepare parameters, removing None values
params = {
"action": action,
"toolName": tool_name,
"tagName": tag_name,
"layerName": layer_name,
}
if prefab_path is not None:
params["prefabPath"] = prefab_path
elif path is not None:
params["path"] = path
params = {k: v for k, v in params.items() if v is not None}

# Send command using centralized retry helper with instance routing
Expand Down
108 changes: 108 additions & 0 deletions Server/tests/test_manage_editor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Tests for the manage_editor tool surface."""

import inspect

import pytest

import services.tools.manage_editor as manage_editor_mod
from services.registry import get_registered_tools
from .integration.test_helpers import DummyContext


def test_manage_editor_prefab_path_parameters_exist():
"""open_prefab_stage should expose prefab_path plus path alias parameters."""
sig = inspect.signature(manage_editor_mod.manage_editor)
assert "prefab_path" in sig.parameters
assert "path" in sig.parameters
assert sig.parameters["prefab_path"].default is None
assert sig.parameters["path"].default is None


def test_manage_editor_description_mentions_open_prefab_stage():
"""The tool description should advertise the new prefab stage action."""
editor_tool = next(
(t for t in get_registered_tools() if t["name"] == "manage_editor"), None
)
assert editor_tool is not None
desc = editor_tool.get("description") or editor_tool.get("kwargs", {}).get("description", "")
assert "open_prefab_stage" in desc


@pytest.mark.asyncio
async def test_manage_editor_open_prefab_stage_forwards_prefab_path(monkeypatch):
"""prefab_path should map to Unity's prefabPath parameter."""
captured = {}

async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "data": {"openedPrefabPath": params["prefabPath"]}}

monkeypatch.setattr(
manage_editor_mod,
"async_send_command_with_retry",
fake_send,
)

resp = await manage_editor_mod.manage_editor(
ctx=DummyContext(),
action="open_prefab_stage",
prefab_path="Assets/Prefabs/Test.prefab",
)

assert resp.get("success") is True
assert captured["cmd"] == "manage_editor"
assert captured["params"]["action"] == "open_prefab_stage"
assert captured["params"]["prefabPath"] == "Assets/Prefabs/Test.prefab"
assert "path" not in captured["params"]


@pytest.mark.asyncio
async def test_manage_editor_open_prefab_stage_accepts_path_alias(monkeypatch):
"""path should remain available as a compatibility alias."""
captured = {}

async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True}

monkeypatch.setattr(
manage_editor_mod,
"async_send_command_with_retry",
fake_send,
)

resp = await manage_editor_mod.manage_editor(
ctx=DummyContext(),
action="open_prefab_stage",
path="Assets/Prefabs/Alias.prefab",
)

assert resp.get("success") is True
assert captured["params"]["action"] == "open_prefab_stage"
assert captured["params"]["path"] == "Assets/Prefabs/Alias.prefab"
assert "prefabPath" not in captured["params"]


@pytest.mark.asyncio
async def test_manage_editor_open_prefab_stage_rejects_conflicting_path_inputs(monkeypatch):
"""Conflicting aliases should fail fast before sending a Unity command."""

async def fake_send(cmd, params, **kwargs): # pragma: no cover - should not be hit
raise AssertionError("send should not be called for conflicting path inputs")

monkeypatch.setattr(
manage_editor_mod,
"async_send_command_with_retry",
fake_send,
)

resp = await manage_editor_mod.manage_editor(
ctx=DummyContext(),
action="open_prefab_stage",
prefab_path="Assets/Prefabs/Primary.prefab",
path="Assets/Prefabs/Alias.prefab",
)

assert resp.get("success") is False
assert "Provide only one of prefab_path or path" in resp.get("message", "")
Loading