Allow the LLMs to read menu items, not just execute them (#263)
* Move the current test to a Tools folder * feat: add env object and disabled flag handling for MCP client configuration * Format manual config specially for Windsurf and Kiro * refactor: extract config JSON building logic into dedicated ConfigJsonBuilder class * refactor: extract unity node population logic into centralized helper method * refactor: only add env property to config for Windsurf and Kiro clients If it ain't broke with the other clients, don't fix... * fix: write UTF-8 without BOM encoding for config files to avoid Windows compatibility issues * fix: enforce UTF-8 encoding without BOM when writing files to disk * refactor: replace execute_menu_item with enhanced manage_menu_item tool supporting list/exists/refresh * Update meta files for older Unity versions * test: add unit tests for menu item management and execution * feat: add tips for paths, script compilation, and menu item usage in asset creation strategy * Use McpLog functionality instead of Unity's Debug * Add telemetry * Annotate parameters More info to LLMs + better validation * Remove the refresh command It's only ever useful in the context of listing menu items * Updated meta files since running in Unity 2021 * Slightly better README * fix: rename server-version.txt to server_version.txt and update menu item descriptionmain
parent
2a992117e2
commit
b5e0446348
|
|
@ -42,7 +42,7 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to
|
|||
* `manage_asset`: Performs asset operations (import, create, modify, delete, etc.).
|
||||
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
|
||||
* `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations.
|
||||
* `execute_menu_item`: Executes a menu item via its path (e.g., "File/Save Project").
|
||||
* `manage_menu_item`: List Unity Editor menu items; and check for their existence or execute them (e.g., execute "File/Save Project").
|
||||
* `apply_text_edits`: Precise text edits with precondition hashes and atomic multi-edit batches.
|
||||
* `script_apply_edits`: Structured C# method/class edits (insert/replace/delete) with safer boundaries.
|
||||
* `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues before/after writes.
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: dfbabf507ab1245178d1a8e745d8d283
|
||||
guid: dfbabf507ab1245178d1a8e745d8d283
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e5441db2ad88a4bc3a8f0868c9471142
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9e4468da1a15349029e52570b84ec4b0
|
||||
guid: 9e4468da1a15349029e52570b84ec4b0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c15ba6502927e4901a43826c43debd7c
|
||||
guid: c15ba6502927e4901a43826c43debd7c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5931268353eab4ea5baa054e6231e824
|
||||
guid: 5931268353eab4ea5baa054e6231e824
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c01321ff6339b4763807adb979c5c427
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
using NUnit.Framework;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Tools.MenuItems;
|
||||
|
||||
namespace MCPForUnityTests.Editor.Tools.MenuItems
|
||||
{
|
||||
public class ManageMenuItemTests
|
||||
{
|
||||
private static JObject ToJO(object o) => JObject.FromObject(o);
|
||||
|
||||
[Test]
|
||||
public void HandleCommand_UnknownAction_ReturnsError()
|
||||
{
|
||||
var res = ManageMenuItem.HandleCommand(new JObject { ["action"] = "unknown_action" });
|
||||
var jo = ToJO(res);
|
||||
Assert.IsFalse((bool)jo["success"], "Expected success false for unknown action");
|
||||
StringAssert.Contains("Unknown action", (string)jo["error"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HandleCommand_List_RoutesAndReturnsArray()
|
||||
{
|
||||
var res = ManageMenuItem.HandleCommand(new JObject { ["action"] = "list" });
|
||||
var jo = ToJO(res);
|
||||
Assert.IsTrue((bool)jo["success"], "Expected success true");
|
||||
Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HandleCommand_Execute_Blacklisted_RoutesAndErrors()
|
||||
{
|
||||
var res = ManageMenuItem.HandleCommand(new JObject { ["action"] = "execute", ["menuPath"] = "File/Quit" });
|
||||
var jo = ToJO(res);
|
||||
Assert.IsFalse((bool)jo["success"], "Expected success false");
|
||||
StringAssert.Contains("blocked for safety", (string)jo["error"], "Expected blacklist message");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HandleCommand_Exists_MissingParam_ReturnsError()
|
||||
{
|
||||
var res = ManageMenuItem.HandleCommand(new JObject { ["action"] = "exists" });
|
||||
var jo = ToJO(res);
|
||||
Assert.IsFalse((bool)jo["success"], "Expected success false when missing menuPath");
|
||||
StringAssert.Contains("Required parameter", (string)jo["error"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 896e8045986eb0d449ee68395479f1d6
|
||||
guid: 2b36e5f577aa1481c8758831c49d8f9d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using NUnit.Framework;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Tools.MenuItems;
|
||||
|
||||
namespace MCPForUnityTests.Editor.Tools.MenuItems
|
||||
{
|
||||
public class MenuItemExecutorTests
|
||||
{
|
||||
private static JObject ToJO(object o) => JObject.FromObject(o);
|
||||
|
||||
[Test]
|
||||
public void Execute_MissingParam_ReturnsError()
|
||||
{
|
||||
var res = MenuItemExecutor.Execute(new JObject());
|
||||
var jo = ToJO(res);
|
||||
Assert.IsFalse((bool)jo["success"], "Expected success false");
|
||||
StringAssert.Contains("Required parameter", (string)jo["error"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Execute_Blacklisted_ReturnsError()
|
||||
{
|
||||
var res = MenuItemExecutor.Execute(new JObject { ["menuPath"] = "File/Quit" });
|
||||
var jo = ToJO(res);
|
||||
Assert.IsFalse((bool)jo["success"], "Expected success false for blacklisted menu");
|
||||
StringAssert.Contains("blocked for safety", (string)jo["error"], "Expected blacklist message");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Execute_NonBlacklisted_ReturnsImmediateSuccess()
|
||||
{
|
||||
// We don't rely on the menu actually existing; execution is delayed and we only check the immediate response shape
|
||||
var res = MenuItemExecutor.Execute(new JObject { ["menuPath"] = "File/Save Project" });
|
||||
var jo = ToJO(res);
|
||||
Assert.IsTrue((bool)jo["success"], "Expected immediate success response");
|
||||
StringAssert.Contains("Attempted to execute menu item", (string)jo["message"], "Expected attempt message");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: ae694b6ac48824768a319eb378e7fb63
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
using NUnit.Framework;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Tools.MenuItems;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace MCPForUnityTests.Editor.Tools.MenuItems
|
||||
{
|
||||
public class MenuItemsReaderTests
|
||||
{
|
||||
private static JObject ToJO(object o) => JObject.FromObject(o);
|
||||
|
||||
[Test]
|
||||
public void List_NoSearch_ReturnsSuccessAndArray()
|
||||
{
|
||||
var res = MenuItemsReader.List(new JObject());
|
||||
var jo = ToJO(res);
|
||||
Assert.IsTrue((bool)jo["success"], "Expected success true");
|
||||
Assert.IsNotNull(jo["data"], "Expected data field present");
|
||||
Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array");
|
||||
|
||||
// Validate list is sorted ascending when there are multiple items
|
||||
var arr = (JArray)jo["data"];
|
||||
if (arr.Count >= 2)
|
||||
{
|
||||
var original = arr.Select(t => (string)t).ToList();
|
||||
var sorted = original.OrderBy(s => s, StringComparer.Ordinal).ToList();
|
||||
CollectionAssert.AreEqual(sorted, original, "Expected menu items to be sorted ascending");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void List_SearchNoMatch_ReturnsEmpty()
|
||||
{
|
||||
var res = MenuItemsReader.List(new JObject { ["search"] = "___unlikely___term___" });
|
||||
var jo = ToJO(res);
|
||||
Assert.IsTrue((bool)jo["success"], "Expected success true");
|
||||
Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array");
|
||||
Assert.AreEqual(0, jo["data"].Count(), "Expected no results for unlikely search term");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void List_SearchMatchesExistingItem_ReturnsContainingItem()
|
||||
{
|
||||
// Get the full list first
|
||||
var listRes = MenuItemsReader.List(new JObject());
|
||||
var listJo = ToJO(listRes);
|
||||
if (listJo["data"] is JArray arr && arr.Count > 0)
|
||||
{
|
||||
var first = (string)arr[0];
|
||||
// Use a mid-substring (case-insensitive) to avoid edge cases
|
||||
var term = first.Length > 4 ? first.Substring(1, Math.Min(3, first.Length - 2)) : first;
|
||||
term = term.ToLowerInvariant();
|
||||
|
||||
var res = MenuItemsReader.List(new JObject { ["search"] = term });
|
||||
var jo = ToJO(res);
|
||||
Assert.IsTrue((bool)jo["success"], "Expected success true");
|
||||
Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array");
|
||||
// Expect at least the original item to be present
|
||||
var names = ((JArray)jo["data"]).Select(t => (string)t).ToList();
|
||||
CollectionAssert.Contains(names, first, "Expected search results to include the sampled item");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Pass("No menu items available to perform a content-based search assertion.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Exists_MissingParam_ReturnsError()
|
||||
{
|
||||
var res = MenuItemsReader.Exists(new JObject());
|
||||
var jo = ToJO(res);
|
||||
Assert.IsFalse((bool)jo["success"], "Expected success false");
|
||||
StringAssert.Contains("Required parameter", (string)jo["error"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Exists_Bogus_ReturnsFalse()
|
||||
{
|
||||
var res = MenuItemsReader.Exists(new JObject { ["menuPath"] = "Nonexistent/Menu/___unlikely___" });
|
||||
var jo = ToJO(res);
|
||||
Assert.IsTrue((bool)jo["success"], "Expected success true");
|
||||
Assert.IsNotNull(jo["data"], "Expected data field present");
|
||||
Assert.IsFalse((bool)jo["data"]["exists"], "Expected exists false for bogus menu path");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: dbae8d670978f4a2bb525d7da9ed9f34
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
{
|
||||
"m_Name": "Settings",
|
||||
"m_Path": "ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json",
|
||||
"m_Dictionary": {
|
||||
"m_DictionaryValues": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: be61633e00d934610ac1ff8192ffbe3d
|
||||
guid: be61633e00d934610ac1ff8192ffbe3d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b82eaef548d164ca095f17db64d15af8
|
||||
guid: b82eaef548d164ca095f17db64d15af8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ using UnityEngine;
|
|||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Models;
|
||||
using MCPForUnity.Editor.Tools;
|
||||
using MCPForUnity.Editor.Tools.MenuItems;
|
||||
|
||||
namespace MCPForUnity.Editor
|
||||
{
|
||||
|
|
@ -1053,7 +1054,7 @@ namespace MCPForUnity.Editor
|
|||
"manage_asset" => ManageAsset.HandleCommand(paramsObject),
|
||||
"manage_shader" => ManageShader.HandleCommand(paramsObject),
|
||||
"read_console" => ReadConsole.HandleCommand(paramsObject),
|
||||
"execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject),
|
||||
"manage_menu_item" => ManageMenuItem.HandleCommand(paramsObject),
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown or unsupported command type: {command.type}"
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Tools.MenuItems;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
|
|
@ -19,7 +20,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
{ "HandleManageGameObject", ManageGameObject.HandleCommand },
|
||||
{ "HandleManageAsset", ManageAsset.HandleCommand },
|
||||
{ "HandleReadConsole", ReadConsole.HandleCommand },
|
||||
{ "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand },
|
||||
{ "HandleManageMenuItem", ManageMenuItem.HandleCommand },
|
||||
{ "HandleManageShader", ManageShader.HandleCommand},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic; // Added for HashSet
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Editor.Helpers; // For Response class
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles executing Unity Editor menu items by path.
|
||||
/// </summary>
|
||||
public static class ExecuteMenuItem
|
||||
{
|
||||
// Basic blacklist to prevent accidental execution of potentially disruptive menu items.
|
||||
// This can be expanded based on needs.
|
||||
private static readonly HashSet<string> _menuPathBlacklist = new HashSet<string>(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
{
|
||||
"File/Quit",
|
||||
// Add other potentially dangerous items like "Edit/Preferences...", "File/Build Settings..." if needed
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Main handler for executing menu items or getting available ones.
|
||||
/// </summary>
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
string action = (@params["action"]?.ToString())?.ToLowerInvariant() ?? "execute"; // Default action
|
||||
|
||||
try
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "execute":
|
||||
return ExecuteItem(@params);
|
||||
case "get_available_menus":
|
||||
// Getting a comprehensive list of *all* menu items dynamically is very difficult
|
||||
// and often requires complex reflection or maintaining a manual list.
|
||||
// Returning a placeholder/acknowledgement for now.
|
||||
Debug.LogWarning(
|
||||
"[ExecuteMenuItem] 'get_available_menus' action is not fully implemented. Dynamically listing all menu items is complex."
|
||||
);
|
||||
// Returning an empty list as per the refactor plan's requirements.
|
||||
return Response.Success(
|
||||
"'get_available_menus' action is not fully implemented. Returning empty list.",
|
||||
new List<string>()
|
||||
);
|
||||
// TODO: Consider implementing a basic list of common/known menu items or exploring reflection techniques if this feature becomes critical.
|
||||
default:
|
||||
return Response.Error(
|
||||
$"Unknown action: '{action}'. Valid actions are 'execute', 'get_available_menus'."
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[ExecuteMenuItem] Action '{action}' failed: {e}");
|
||||
return Response.Error($"Internal error processing action '{action}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a specific menu item.
|
||||
/// </summary>
|
||||
private static object ExecuteItem(JObject @params)
|
||||
{
|
||||
// Try both naming conventions: snake_case and camelCase
|
||||
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
|
||||
// Optional future param retained for API compatibility; not used in synchronous mode
|
||||
// int timeoutMs = Math.Max(0, (@params["timeout_ms"]?.ToObject<int>() ?? 2000));
|
||||
|
||||
// string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements.
|
||||
// JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem).
|
||||
|
||||
if (string.IsNullOrWhiteSpace(menuPath))
|
||||
{
|
||||
return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty.");
|
||||
}
|
||||
|
||||
// Validate against blacklist
|
||||
if (_menuPathBlacklist.Contains(menuPath))
|
||||
{
|
||||
return Response.Error(
|
||||
$"Execution of menu item '{menuPath}' is blocked for safety reasons."
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Implement alias lookup here if needed (Map alias to actual menuPath).
|
||||
// if (!string.IsNullOrEmpty(alias)) { menuPath = LookupAlias(alias); if(menuPath == null) return Response.Error(...); }
|
||||
|
||||
// TODO: Handle parameters ('parameters' object) if a viable method is found.
|
||||
// This is complex as EditorApplication.ExecuteMenuItem doesn't take arguments directly.
|
||||
// It might require finding the underlying EditorWindow or command if parameters are needed.
|
||||
|
||||
try
|
||||
{
|
||||
// Trace incoming execute requests (debug-gated)
|
||||
McpLog.Info($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'", always: false);
|
||||
|
||||
// Execute synchronously. This code runs on the Editor main thread in our bridge path.
|
||||
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
|
||||
if (executed)
|
||||
{
|
||||
// Success trace (debug-gated)
|
||||
McpLog.Info($"[ExecuteMenuItem] Executed successfully: '{menuPath}'", always: false);
|
||||
return Response.Success(
|
||||
$"Executed menu item: '{menuPath}'",
|
||||
new { executed = true, menuPath }
|
||||
);
|
||||
}
|
||||
Debug.LogWarning($"[ExecuteMenuItem] Failed (not found/disabled): '{menuPath}'");
|
||||
return Response.Error(
|
||||
$"Failed to execute menu item (not found or disabled): '{menuPath}'",
|
||||
new { executed = false, menuPath }
|
||||
);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}");
|
||||
return Response.Error($"Error executing menu item '{menuPath}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add helper for alias lookup if implementing aliases.
|
||||
// private static string LookupAlias(string alias) { ... return actualMenuPath or null ... }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2df8f144c6e684ec3bfd53e4a48f06ee
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.MenuItems
|
||||
{
|
||||
/// <summary>
|
||||
/// Facade handler for managing Unity Editor menu items.
|
||||
/// Routes actions to read or execute implementations.
|
||||
/// </summary>
|
||||
public static class ManageMenuItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Routes actions: execute, list, exists, refresh
|
||||
/// </summary>
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
string action = @params["action"]?.ToString()?.ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(action))
|
||||
{
|
||||
return Response.Error("Action parameter is required. Valid actions are: execute, list, exists, refresh.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "execute":
|
||||
return MenuItemExecutor.Execute(@params);
|
||||
case "list":
|
||||
return MenuItemsReader.List(@params);
|
||||
case "exists":
|
||||
return MenuItemsReader.Exists(@params);
|
||||
default:
|
||||
return Response.Error($"Unknown action: '{action}'. Valid actions are: execute, list, exists, refresh.");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
McpLog.Error($"[ManageMenuItem] Action '{action}' failed: {e}");
|
||||
return Response.Error($"Internal error: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 77808278b21a6474a90f3abb91483f71
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.MenuItems
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes Unity Editor menu items by path with safety checks.
|
||||
/// </summary>
|
||||
public static class MenuItemExecutor
|
||||
{
|
||||
// Basic blacklist to prevent execution of disruptive menu items.
|
||||
private static readonly HashSet<string> _menuPathBlacklist = new HashSet<string>(
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"File/Quit",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Execute a specific menu item. Expects 'menu_path' or 'menuPath' in params.
|
||||
/// </summary>
|
||||
public static object Execute(JObject @params)
|
||||
{
|
||||
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(menuPath))
|
||||
{
|
||||
return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty.");
|
||||
}
|
||||
|
||||
if (_menuPathBlacklist.Contains(menuPath))
|
||||
{
|
||||
return Response.Error($"Execution of menu item '{menuPath}' is blocked for safety reasons.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Execute on main thread using delayCall
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
|
||||
if (!executed)
|
||||
{
|
||||
McpLog.Error($"[MenuItemExecutor] Failed to execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent.");
|
||||
}
|
||||
}
|
||||
catch (Exception delayEx)
|
||||
{
|
||||
McpLog.Error($"[MenuItemExecutor] Exception during delayed execution of '{menuPath}': {delayEx}");
|
||||
}
|
||||
};
|
||||
|
||||
return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors.");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
McpLog.Error($"[MenuItemExecutor] Failed to setup execution for '{menuPath}': {e}");
|
||||
return Response.Error($"Error setting up execution for menu item '{menuPath}': {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1ccc7c6ff549542e1ae4ba3463ae79d2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.MenuItems
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides read/list/exists capabilities for Unity menu items with caching.
|
||||
/// </summary>
|
||||
public static class MenuItemsReader
|
||||
{
|
||||
private static List<string> _cached;
|
||||
|
||||
[InitializeOnLoadMethod]
|
||||
private static void Build() => Refresh();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached list, refreshing if necessary.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> AllMenuItems() => _cached ??= Refresh();
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the cached list from reflection.
|
||||
/// </summary>
|
||||
private static List<string> Refresh()
|
||||
{
|
||||
try
|
||||
{
|
||||
var methods = TypeCache.GetMethodsWithAttribute<MenuItem>();
|
||||
_cached = methods
|
||||
// Methods can have multiple [MenuItem] attributes; collect them all
|
||||
.SelectMany(m => m
|
||||
.GetCustomAttributes(typeof(MenuItem), false)
|
||||
.OfType<MenuItem>()
|
||||
.Select(attr => attr.menuItem))
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.Ordinal) // Ensure no duplicates
|
||||
.OrderBy(s => s, StringComparer.Ordinal) // Ensure consistent ordering
|
||||
.ToList();
|
||||
return _cached;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
McpLog.Error($"[MenuItemsReader] Failed to scan menu items: {e}");
|
||||
_cached = _cached ?? new List<string>();
|
||||
return _cached;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of menu items. Optional 'search' param filters results.
|
||||
/// </summary>
|
||||
public static object List(JObject @params)
|
||||
{
|
||||
string search = @params["search"]?.ToString();
|
||||
bool doRefresh = @params["refresh"]?.ToObject<bool>() ?? false;
|
||||
if (doRefresh || _cached == null)
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
|
||||
IEnumerable<string> result = _cached ?? Enumerable.Empty<string>();
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
{
|
||||
result = result.Where(s => s.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
}
|
||||
|
||||
return Response.Success("Menu items retrieved.", result.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a given menu path exists in the cache.
|
||||
/// </summary>
|
||||
public static object Exists(JObject @params)
|
||||
{
|
||||
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(menuPath))
|
||||
{
|
||||
return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty.");
|
||||
}
|
||||
|
||||
bool doRefresh = @params["refresh"]?.ToObject<bool>() ?? false;
|
||||
if (doRefresh || _cached == null)
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
|
||||
bool exists = (_cached ?? new List<string>()).Contains(menuPath);
|
||||
return Response.Success($"Exists check completed for '{menuPath}'.", new { exists });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 37f212f83e8854ed7b5454d3733e4bfa
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1 +0,0 @@
|
|||
3.3.0
|
||||
|
|
@ -62,6 +62,7 @@ from telemetry import record_telemetry, record_milestone, RecordType, MilestoneT
|
|||
# Global connection state
|
||||
_unity_connection: UnityConnection = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Handle server startup and shutdown."""
|
||||
|
|
@ -73,7 +74,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
|||
start_clk = time.perf_counter()
|
||||
try:
|
||||
from pathlib import Path
|
||||
ver_path = Path(__file__).parent / "server-version.txt"
|
||||
ver_path = Path(__file__).parent / "server_version.txt"
|
||||
server_version = ver_path.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
server_version = "unknown"
|
||||
|
|
@ -159,13 +160,14 @@ register_all_tools(mcp)
|
|||
|
||||
# Asset Creation Strategy
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def asset_creation_strategy() -> str:
|
||||
"""Guide for discovering and using MCP for Unity tools effectively."""
|
||||
return (
|
||||
"Available MCP for Unity Server Tools:\n\n"
|
||||
"- `manage_editor`: Controls editor state and queries info.\n"
|
||||
"- `execute_menu_item`: Executes Unity Editor menu items by path.\n"
|
||||
"- `manage_menu_item`: Executes, lists and checks for the existence of Unity Editor menu items.\n"
|
||||
"- `read_console`: Reads or clears Unity console messages, with filtering options.\n"
|
||||
"- `manage_scene`: Manages scenes.\n"
|
||||
"- `manage_gameobject`: Manages GameObjects in the scene.\n"
|
||||
|
|
@ -175,8 +177,14 @@ def asset_creation_strategy() -> str:
|
|||
"Tips:\n"
|
||||
"- Create prefabs for reusable GameObjects.\n"
|
||||
"- Always include a camera and main light in your scenes.\n"
|
||||
"- Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n"
|
||||
"- After creating or modifying scripts with `manage_script`, allow Unity to recompile; use `read_console` to check for compile errors.\n"
|
||||
"- Use `manage_menu_item` for interacting with Unity systems and third party tools like a user would.\n"
|
||||
"- List menu items before using them if you are unsure of the menu path.\n"
|
||||
"- If a menu item seems missing, refresh the cache: use manage_menu_item with action='list' and refresh=true, or action='refresh'. Avoid refreshing every time; prefer refresh only when the menu set likely changed.\n"
|
||||
)
|
||||
|
||||
|
||||
# Run the server
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport='stdio')
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.3.2
|
||||
3.4.0
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from .manage_gameobject import register_manage_gameobject_tools
|
|||
from .manage_asset import register_manage_asset_tools
|
||||
from .manage_shader import register_manage_shader_tools
|
||||
from .read_console import register_read_console_tools
|
||||
from .execute_menu_item import register_execute_menu_item_tools
|
||||
from .manage_menu_item import register_manage_menu_item_tools
|
||||
from .resource_tools import register_resource_tools
|
||||
|
||||
logger = logging.getLogger("mcp-for-unity-server")
|
||||
|
|
@ -24,7 +24,6 @@ def register_all_tools(mcp):
|
|||
register_manage_asset_tools(mcp)
|
||||
register_manage_shader_tools(mcp)
|
||||
register_read_console_tools(mcp)
|
||||
register_execute_menu_item_tools(mcp)
|
||||
# Expose resource wrappers as normal tools so IDEs without resources primitive can use them
|
||||
register_manage_menu_item_tools(mcp)
|
||||
register_resource_tools(mcp)
|
||||
logger.info("MCP for Unity Server tool registration complete.")
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
"""
|
||||
Defines the execute_menu_item tool for running Unity Editor menu commands.
|
||||
"""
|
||||
from typing import Dict, Any
|
||||
from mcp.server.fastmcp import FastMCP, Context
|
||||
from unity_connection import get_unity_connection, send_command_with_retry # Import retry helper
|
||||
from config import config
|
||||
import time
|
||||
|
||||
from telemetry_decorator import telemetry_tool
|
||||
|
||||
def register_execute_menu_item_tools(mcp: FastMCP):
|
||||
"""Registers the execute_menu_item tool with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@telemetry_tool("execute_menu_item")
|
||||
def execute_menu_item(
|
||||
ctx: Any,
|
||||
menu_path: str,
|
||||
action: str = 'execute',
|
||||
parameters: Dict[str, Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Executes a Unity Editor menu item via its path (e.g., "File/Save Project").
|
||||
|
||||
Args:
|
||||
ctx: The MCP context.
|
||||
menu_path: The full path of the menu item to execute.
|
||||
action: The operation to perform (default: 'execute').
|
||||
parameters: Optional parameters for the menu item (rarely used).
|
||||
|
||||
Returns:
|
||||
A dictionary indicating success or failure, with optional message/error.
|
||||
"""
|
||||
|
||||
action = action.lower() if action else 'execute'
|
||||
|
||||
# Prepare parameters for the C# handler
|
||||
params_dict = {
|
||||
"action": action,
|
||||
"menuPath": menu_path,
|
||||
"parameters": parameters if parameters else {},
|
||||
}
|
||||
|
||||
# Remove None values
|
||||
params_dict = {k: v for k, v in params_dict.items() if v is not None}
|
||||
|
||||
if "parameters" not in params_dict:
|
||||
params_dict["parameters"] = {} # Ensure parameters dict exists
|
||||
|
||||
# Use centralized retry helper
|
||||
resp = send_command_with_retry("execute_menu_item", params_dict)
|
||||
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"""
|
||||
Defines the manage_menu_item tool for executing and reading Unity Editor menu items.
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from mcp.server.fastmcp import FastMCP, Context
|
||||
from telemetry_decorator import telemetry_tool
|
||||
|
||||
from unity_connection import get_unity_connection, async_send_command_with_retry
|
||||
|
||||
|
||||
def register_manage_menu_item_tools(mcp: FastMCP):
|
||||
"""Registers the manage_menu_item tool with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@telemetry_tool("manage_menu_item")
|
||||
async def manage_menu_item(
|
||||
ctx: Context,
|
||||
action: Annotated[Literal["execute", "list", "exists"], "One of 'execute', 'list', 'exists'"],
|
||||
menu_path: Annotated[str | None,
|
||||
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] = None,
|
||||
search: Annotated[str | None,
|
||||
"Optional filter string for 'list' (e.g., 'Save')"] = None,
|
||||
refresh: Annotated[bool | None,
|
||||
"Optional flag to force refresh of the menu cache when listing"] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Manage Unity menu items (execute/list/exists).
|
||||
|
||||
Args:
|
||||
ctx: The MCP context.
|
||||
action: One of 'execute', 'list', 'exists'.
|
||||
menu_path: Menu path for 'execute' or 'exists' (e.g., "File/Save Project").
|
||||
search: Optional filter string for 'list'.
|
||||
refresh: Optional flag to force refresh of the menu cache when listing.
|
||||
|
||||
Returns:
|
||||
A dictionary with operation results ('success', 'data', 'error').
|
||||
"""
|
||||
# Prepare parameters for the C# handler
|
||||
params_dict: dict[str, Any] = {
|
||||
"action": action,
|
||||
"menuPath": menu_path,
|
||||
"search": search,
|
||||
"refresh": refresh,
|
||||
}
|
||||
# Remove None values
|
||||
params_dict = {k: v for k, v in params_dict.items() if v is not None}
|
||||
|
||||
# Get the current asyncio event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
# Touch the connection to ensure availability (mirrors other tools' pattern)
|
||||
_ = get_unity_connection()
|
||||
|
||||
# Use centralized async retry helper
|
||||
result = await async_send_command_with_retry("manage_menu_item", params_dict, loop=loop)
|
||||
return result if isinstance(result, dict) else {"success": False, "message": str(result)}
|
||||
Loading…
Reference in New Issue