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 description
main
Marcus Sanatan 2025-09-12 11:19:58 -04:00 committed by GitHub
parent 2a992117e2
commit b5e0446348
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 600 additions and 199 deletions

View File

@ -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_asset`: Performs asset operations (import, create, modify, delete, etc.).
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). * `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
* `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations. * `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. * `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. * `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. * `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues before/after writes.

View File

@ -1,2 +1,11 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: dfbabf507ab1245178d1a8e745d8d283 guid: dfbabf507ab1245178d1a8e745d8d283
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e5441db2ad88a4bc3a8f0868c9471142
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,2 +1,11 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 9e4468da1a15349029e52570b84ec4b0 guid: 9e4468da1a15349029e52570b84ec4b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,2 +1,11 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: c15ba6502927e4901a43826c43debd7c guid: c15ba6502927e4901a43826c43debd7c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,2 +1,11 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 5931268353eab4ea5baa054e6231e824 guid: 5931268353eab4ea5baa054e6231e824
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c01321ff6339b4763807adb979c5c427
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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"]);
}
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 896e8045986eb0d449ee68395479f1d6 guid: 2b36e5f577aa1481c8758831c49d8f9d
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -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");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ae694b6ac48824768a319eb378e7fb63
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dbae8d670978f4a2bb525d7da9ed9f34
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,4 +1,6 @@
{ {
"m_Name": "Settings",
"m_Path": "ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json",
"m_Dictionary": { "m_Dictionary": {
"m_DictionaryValues": [] "m_DictionaryValues": []
} }

View File

@ -1,2 +1,11 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: be61633e00d934610ac1ff8192ffbe3d guid: be61633e00d934610ac1ff8192ffbe3d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,2 +1,11 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: b82eaef548d164ca095f17db64d15af8 guid: b82eaef548d164ca095f17db64d15af8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -14,6 +14,7 @@ using UnityEngine;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Tools; using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Tools.MenuItems;
namespace MCPForUnity.Editor namespace MCPForUnity.Editor
{ {
@ -1053,7 +1054,7 @@ namespace MCPForUnity.Editor
"manage_asset" => ManageAsset.HandleCommand(paramsObject), "manage_asset" => ManageAsset.HandleCommand(paramsObject),
"manage_shader" => ManageShader.HandleCommand(paramsObject), "manage_shader" => ManageShader.HandleCommand(paramsObject),
"read_console" => ReadConsole.HandleCommand(paramsObject), "read_console" => ReadConsole.HandleCommand(paramsObject),
"execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject), "manage_menu_item" => ManageMenuItem.HandleCommand(paramsObject),
_ => throw new ArgumentException( _ => throw new ArgumentException(
$"Unknown or unsupported command type: {command.type}" $"Unknown or unsupported command type: {command.type}"
), ),

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Tools.MenuItems;
namespace MCPForUnity.Editor.Tools namespace MCPForUnity.Editor.Tools
{ {
@ -19,7 +20,7 @@ namespace MCPForUnity.Editor.Tools
{ "HandleManageGameObject", ManageGameObject.HandleCommand }, { "HandleManageGameObject", ManageGameObject.HandleCommand },
{ "HandleManageAsset", ManageAsset.HandleCommand }, { "HandleManageAsset", ManageAsset.HandleCommand },
{ "HandleReadConsole", ReadConsole.HandleCommand }, { "HandleReadConsole", ReadConsole.HandleCommand },
{ "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand }, { "HandleManageMenuItem", ManageMenuItem.HandleCommand },
{ "HandleManageShader", ManageShader.HandleCommand}, { "HandleManageShader", ManageShader.HandleCommand},
}; };

View File

@ -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 ... }
}
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2df8f144c6e684ec3bfd53e4a48f06ee
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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}");
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 77808278b21a6474a90f3abb91483f71
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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}");
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1ccc7c6ff549542e1ae4ba3463ae79d2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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 });
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 37f212f83e8854ed7b5454d3733e4bfa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -62,6 +62,7 @@ from telemetry import record_telemetry, record_milestone, RecordType, MilestoneT
# Global connection state # Global connection state
_unity_connection: UnityConnection = None _unity_connection: UnityConnection = None
@asynccontextmanager @asynccontextmanager
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
"""Handle server startup and shutdown.""" """Handle server startup and shutdown."""
@ -73,7 +74,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
start_clk = time.perf_counter() start_clk = time.perf_counter()
try: try:
from pathlib import Path 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() server_version = ver_path.read_text(encoding="utf-8").strip()
except Exception: except Exception:
server_version = "unknown" server_version = "unknown"
@ -159,13 +160,14 @@ register_all_tools(mcp)
# Asset Creation Strategy # Asset Creation Strategy
@mcp.prompt() @mcp.prompt()
def asset_creation_strategy() -> str: def asset_creation_strategy() -> str:
"""Guide for discovering and using MCP for Unity tools effectively.""" """Guide for discovering and using MCP for Unity tools effectively."""
return ( return (
"Available MCP for Unity Server Tools:\n\n" "Available MCP for Unity Server Tools:\n\n"
"- `manage_editor`: Controls editor state and queries info.\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" "- `read_console`: Reads or clears Unity console messages, with filtering options.\n"
"- `manage_scene`: Manages scenes.\n" "- `manage_scene`: Manages scenes.\n"
"- `manage_gameobject`: Manages GameObjects in the scene.\n" "- `manage_gameobject`: Manages GameObjects in the scene.\n"
@ -175,8 +177,14 @@ def asset_creation_strategy() -> str:
"Tips:\n" "Tips:\n"
"- Create prefabs for reusable GameObjects.\n" "- Create prefabs for reusable GameObjects.\n"
"- Always include a camera and main light in your scenes.\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 # Run the server
if __name__ == "__main__": if __name__ == "__main__":
mcp.run(transport='stdio') mcp.run(transport='stdio')

View File

@ -1 +1 @@
3.3.2 3.4.0

View File

@ -7,7 +7,7 @@ from .manage_gameobject import register_manage_gameobject_tools
from .manage_asset import register_manage_asset_tools from .manage_asset import register_manage_asset_tools
from .manage_shader import register_manage_shader_tools from .manage_shader import register_manage_shader_tools
from .read_console import register_read_console_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 from .resource_tools import register_resource_tools
logger = logging.getLogger("mcp-for-unity-server") logger = logging.getLogger("mcp-for-unity-server")
@ -24,7 +24,6 @@ def register_all_tools(mcp):
register_manage_asset_tools(mcp) register_manage_asset_tools(mcp)
register_manage_shader_tools(mcp) register_manage_shader_tools(mcp)
register_read_console_tools(mcp) register_read_console_tools(mcp)
register_execute_menu_item_tools(mcp) register_manage_menu_item_tools(mcp)
# Expose resource wrappers as normal tools so IDEs without resources primitive can use them
register_resource_tools(mcp) register_resource_tools(mcp)
logger.info("MCP for Unity Server tool registration complete.") logger.info("MCP for Unity Server tool registration complete.")

View File

@ -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)}

View File

@ -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)}