From c6969fdc5227bb7d7a2aa7b8be5049a54b219d37 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 6 Aug 2025 20:48:57 -0400 Subject: [PATCH 01/69] docs: update repository links and Discord invite to CoplayDev organization --- README.md | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index fd097f1d..38d05bf1 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ [![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive) [![python](https://img.shields.io/badge/Python-3.12-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org) [![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction) -![GitHub commit activity](https://img.shields.io/github/commit-activity/w/justinpbarnett/unity-mcp) -![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/justinpbarnett/unity-mcp) +![GitHub commit activity](https://img.shields.io/github/commit-activity/w/coplaydev/unity-mcp) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/coplaydev/unity-mcp) [![](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT) **Create your Unity apps with LLMs!** @@ -13,7 +13,7 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte ## 💬 Join Our Community -### [Discord](https://discord.gg/vhTUxXaqYr) +### [Discord](https://discord.gg/y4p8KfzrN4) **Get help, share ideas, and collaborate with other Unity MCP developers!** @@ -106,7 +106,7 @@ Unity MCP connects your tools using two components: 3. Click `+` -> `Add package from git URL...`. 4. Enter: ``` - https://github.com/justinpbarnett/unity-mcp.git?path=/UnityMcpBridge + https://github.com/coplaydev/unity-mcp.git?path=/UnityMcpBridge ``` 5. Click `Add`. 6. The MCP Server should automatically be installed onto your machine as a result of this process. @@ -290,7 +290,6 @@ Help make Unity MCP better! 5. **Push** your branch. 6. **Open a Pull Request** against the master branch. - --- @@ -311,42 +310,40 @@ Help make Unity MCP better! - **Verify Server Path:** Double-check the --directory path in your MCP Client's JSON config. It must exactly match the location where you cloned the UnityMCP repository in Installation Step 1 (e.g., .../Programs/UnityMCP/UnityMcpServer/src). - - **Verify uv:** Make sure uv is installed and working (pip show uv). + - **Verify uv:** Make sure `uv` is installed and working (pip show uv). - **Run Manually:** Try running the server directly from the terminal to see errors: `# Navigate to the src directory first! cd /path/to/your/UnityMCP/UnityMcpServer/src uv run server.py` - **Permissions (macOS/Linux):** If you installed the server in a system location like /usr/local/bin, ensure the user running the MCP client has permission to execute uv and access files there. Installing in ~/bin might be easier. - **Auto-Configure Failed:** - + - Use the Manual Configuration steps. Auto-configure might lack permissions to write to the MCP client's config file. - + -Still stuck? [Open an Issue](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgithub.com%2Fjustinpbarnett%2Funity-mcp%2Fissues) or [Join the Discord](https://discord.gg/vhTUxXaqYr)! +Still stuck? [Open an Issue](https://github.com/coplaydev/unity-mcp/issues) or [Join the Discord](https://discord.gg/y4p8KfzrN4)! --- ## Contact 👋 -- **justinpbarnett:** [X/Twitter](https://www.google.com/url?sa=E&q=https%3A%2F%2Fx.com%2Fjustinpbarnett) +- **justinpbarnett:** [X/Twitter](https://x.com/justinpbarnett) - **scriptwonder**: [Email](mailto:swu85@ur.rochester.edu), [LinkedIn](https://www.linkedin.com/in/shutong-wu-214043172/) - --- ## License 📜 -MIT License. See [LICENSE](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgithub.com%2Fjustinpbarnett%2Funity-mcp%2Fblob%2Fmaster%2FLICENSE) file. +MIT License. See [LICENSE](https://github.com/CoplayDev/unity-mcp/blob/master/LICENSE) file. --- ## Acknowledgments 🙏 -Thanks to the contributors and the Unity team. - +Thanks to the contributors and our sponsors [Coplay](https://coplay.dev). ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=justinpbarnett/unity-mcp&type=Date)](https://www.star-history.com/#justinpbarnett/unity-mcp&Date) +[![Star History Chart](https://api.star-history.com/svg?repos=coplaydev/unity-mcp&type=Date)](https://www.star-history.com/#coplaydev/unity-mcp&Date) From 3dcaeca362f86fa5d2da76f1c76297600adefb4c Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 6 Aug 2025 20:59:02 -0400 Subject: [PATCH 02/69] docs: add Coplay sponsorship and logo to README --- README.md | 8 ++++++++ logo.png | Bin 0 -> 40503 bytes 2 files changed, 8 insertions(+) create mode 100644 logo.png diff --git a/README.md b/README.md index 38d05bf1..d415c05d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,14 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to interact directly with your Unity Editor via a local **MCP (Model Context Protocol) Client**. Give your LLM tools to manage assets, control scenes, edit scripts, and automate tasks within Unity. +Proudly sponsored by [Coplay](https://www.coplay.dev) + +

+ + Coplay Logo + +

+ ## 💬 Join Our Community ### [Discord](https://discord.gg/y4p8KfzrN4) diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9472a36068e9c721e58811c30f65dfd2a9fc6693 GIT binary patch literal 40503 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 IHt|XRFY90JK>z>% literal 0 HcmV?d00001 From 39ec588cbe3c5bcd5e8781f1b886701547c038ab Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 6 Aug 2025 20:59:47 -0400 Subject: [PATCH 03/69] Try logo at 50% width --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d415c05d..9c2d2602 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Proudly sponsored by [Coplay](https://www.coplay.dev)

- Coplay Logo + Coplay Logo

From 164bba43dfd354347901701d0abd3fa19e0d7f1e Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 6 Aug 2025 21:14:10 -0400 Subject: [PATCH 04/69] style: adjust Coplay logo size and container element in README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9c2d2602..d41582de 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,11 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte Proudly sponsored by [Coplay](https://www.coplay.dev) -

+

- Coplay Logo + Coplay Logo -

+

## 💬 Join Our Community From b7ea2c88e3d7d80754f585e2ea2591a88612291f Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 6 Aug 2025 21:18:35 -0400 Subject: [PATCH 05/69] docs: update license link to use relative path --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d41582de..94649489 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ Still stuck? [Open an Issue](https://github.com/coplaydev/unity-mcp/issues) or [ ## License 📜 -MIT License. See [LICENSE](https://github.com/CoplayDev/unity-mcp/blob/master/LICENSE) file. +MIT License. See [LICENSE](LICENSE) file. --- From 32274a396586988addd6f7babc89509b826f17f6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 7 Aug 2025 15:32:03 -0700 Subject: [PATCH 06/69] UnityMCP stability: robust auto-restart on compile/play transitions; stop on domain reload; start/stop locking; per-project sticky ports + brief release wait; Python discovery scans hashed+legacy files and probes; editor window live status refresh. --- .../Editor/Data/DefaultServerConfig.cs.meta | 11 +- UnityMcpBridge/Editor/Data/McpClients.cs.meta | 11 +- .../Helpers/GameObjectSerializer.cs.meta | 11 +- UnityMcpBridge/Editor/Helpers/PortManager.cs | 135 +++++++++++++-- .../Editor/Helpers/Response.cs.meta | 11 +- .../Editor/Helpers/ServerInstaller.cs.meta | 11 +- .../Editor/Helpers/Vector3Helper.cs.meta | 11 +- UnityMcpBridge/Editor/Models/Command.cs.meta | 11 +- .../Editor/Models/MCPConfigServer.cs.meta | 11 +- .../Editor/Models/MCPConfigServers.cs.meta | 11 +- .../Editor/Models/McpClient.cs.meta | 11 +- .../Editor/Models/McpConfig.cs.meta | 11 +- .../Editor/Models/McpStatus.cs.meta | 11 +- UnityMcpBridge/Editor/Models/McpTypes.cs.meta | 11 +- .../Editor/Models/ServerConfig.cs.meta | 11 +- .../Editor/Tools/CommandRegistry.cs.meta | 11 +- .../Editor/Tools/ExecuteMenuItem.cs.meta | 11 +- .../Editor/Tools/ManageAsset.cs.meta | 11 +- .../Editor/Tools/ManageEditor.cs.meta | 11 +- .../Editor/Tools/ManageGameObject.cs.meta | 11 +- .../Editor/Tools/ManageScene.cs.meta | 11 +- .../Editor/Tools/ManageScript.cs.meta | 11 +- .../Editor/Tools/ReadConsole.cs.meta | 11 +- UnityMcpBridge/Editor/UnityMcpBridge.cs | 161 +++++++++++++----- UnityMcpBridge/Editor/UnityMcpBridge.cs.meta | 11 +- .../Windows/ManualConfigEditorWindow.cs.meta | 11 +- .../Windows/UnityMCPEditorWindow.cs.meta | 11 +- .../Editor/Windows/UnityMcpEditorWindow.cs | 10 +- .../Windows/VSCodeManualSetupWindow.cs.meta | 11 +- .../Serialization/UnityTypeConverters.cs.meta | 11 +- UnityMcpServer/src/port_discovery.py | 126 ++++++++++---- 31 files changed, 614 insertions(+), 115 deletions(-) diff --git a/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta b/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta index 6df0a871..82e437f2 100644 --- a/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta +++ b/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: de8f5721c34f7194392e9d8c7d0226c0 \ No newline at end of file +guid: de8f5721c34f7194392e9d8c7d0226c0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs.meta b/UnityMcpBridge/Editor/Data/McpClients.cs.meta index 3c8449ae..e5a10813 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs.meta +++ b/UnityMcpBridge/Editor/Data/McpClients.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 711b86bbc1f661e4fb2c822e14970e16 \ No newline at end of file +guid: 711b86bbc1f661e4fb2c822e14970e16 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta b/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta index d8df9686..9eb69d04 100644 --- a/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta +++ b/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 64b8ff807bc9a401c82015cbafccffac \ No newline at end of file +guid: 64b8ff807bc9a401c82015cbafccffac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/PortManager.cs b/UnityMcpBridge/Editor/Helpers/PortManager.cs index 8e368a6a..900cbd9a 100644 --- a/UnityMcpBridge/Editor/Helpers/PortManager.cs +++ b/UnityMcpBridge/Editor/Helpers/PortManager.cs @@ -2,6 +2,9 @@ using System.IO; using System.Net; using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; using Newtonsoft.Json; using UnityEngine; @@ -31,15 +34,28 @@ public class PortConfig /// Port number to use public static int GetPortWithFallback() { - // Try to load stored port first - int storedPort = LoadStoredPort(); - if (storedPort > 0 && IsPortAvailable(storedPort)) + // Try to load stored port first, but only if it's from the current project + var storedConfig = GetStoredPortConfig(); + if (storedConfig != null && + storedConfig.unity_port > 0 && + storedConfig.project_path == Application.dataPath && + IsPortAvailable(storedConfig.unity_port)) { - Debug.Log($"Using stored port {storedPort}"); - return storedPort; + Debug.Log($"Using stored port {storedConfig.unity_port} for current project"); + return storedConfig.unity_port; } - // If no stored port or stored port is unavailable, find a new one + // If stored port exists but is currently busy, wait briefly for release + if (storedConfig != null && storedConfig.unity_port > 0) + { + if (WaitForPortRelease(storedConfig.unity_port, 1500)) + { + Debug.Log($"Stored port {storedConfig.unity_port} became available after short wait"); + return storedConfig.unity_port; + } + } + + // If no valid stored port, find a new one and save it int newPort = FindAvailablePort(); SavePort(newPort); return newPort; @@ -86,7 +102,7 @@ private static int FindAvailablePort() } /// - /// Check if a specific port is available + /// Check if a specific port is available for binding /// /// Port to check /// True if port is available @@ -105,6 +121,61 @@ public static bool IsPortAvailable(int port) } } + /// + /// Check if a port is currently being used by Unity MCP Bridge + /// This helps avoid unnecessary port changes when Unity itself is using the port + /// + /// Port to check + /// True if port appears to be used by Unity MCP + public static bool IsPortUsedByUnityMcp(int port) + { + try + { + // Try to make a quick connection to see if it's a Unity MCP server + using var client = new TcpClient(); + var connectTask = client.ConnectAsync(IPAddress.Loopback, port); + if (connectTask.Wait(100)) // 100ms timeout + { + // If connection succeeded, it's likely the Unity MCP server + return client.Connected; + } + return false; + } + catch + { + return false; + } + } + + /// + /// Wait for a port to become available for a limited amount of time. + /// Used to bridge the gap during domain reload when the old listener + /// hasn't released the socket yet. + /// + private static bool WaitForPortRelease(int port, int timeoutMs) + { + int waited = 0; + const int step = 100; + while (waited < timeoutMs) + { + if (IsPortAvailable(port)) + { + return true; + } + + // If the port is in use by an MCP instance, continue waiting briefly + if (!IsPortUsedByUnityMcp(port)) + { + // In use by something else; don't keep waiting + return false; + } + + Thread.Sleep(step); + waited += step; + } + return IsPortAvailable(port); + } + /// /// Save port to persistent storage /// @@ -123,7 +194,7 @@ private static void SavePort(int port) string registryDir = GetRegistryDirectory(); Directory.CreateDirectory(registryDir); - string registryFile = Path.Combine(registryDir, RegistryFileName); + string registryFile = GetRegistryFilePath(); string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); File.WriteAllText(registryFile, json); @@ -143,11 +214,17 @@ private static int LoadStoredPort() { try { - string registryFile = Path.Combine(GetRegistryDirectory(), RegistryFileName); + string registryFile = GetRegistryFilePath(); if (!File.Exists(registryFile)) { - return 0; + // Backwards compatibility: try the legacy file name + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + if (!File.Exists(legacy)) + { + return 0; + } + registryFile = legacy; } string json = File.ReadAllText(registryFile); @@ -170,11 +247,17 @@ public static PortConfig GetStoredPortConfig() { try { - string registryFile = Path.Combine(GetRegistryDirectory(), RegistryFileName); + string registryFile = GetRegistryFilePath(); if (!File.Exists(registryFile)) { - return null; + // Backwards compatibility: try the legacy file + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + if (!File.Exists(legacy)) + { + return null; + } + registryFile = legacy; } string json = File.ReadAllText(registryFile); @@ -191,5 +274,33 @@ private static string GetRegistryDirectory() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); } + + private static string GetRegistryFilePath() + { + string dir = GetRegistryDirectory(); + string hash = ComputeProjectHash(Application.dataPath); + string fileName = $"unity-mcp-port-{hash}.json"; + return Path.Combine(dir, fileName); + } + + private static string ComputeProjectHash(string input) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString()[..8]; // short, sufficient for filenames + } + catch + { + return "default"; + } + } } } \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Helpers/Response.cs.meta b/UnityMcpBridge/Editor/Helpers/Response.cs.meta index da593068..6fd11e39 100644 --- a/UnityMcpBridge/Editor/Helpers/Response.cs.meta +++ b/UnityMcpBridge/Editor/Helpers/Response.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 80c09a76b944f8c4691e06c4d76c4be8 \ No newline at end of file +guid: 80c09a76b944f8c4691e06c4d76c4be8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta index 67bd7f4e..dfd9023b 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 5862c6a6d0a914f4d83224f8d039cf7b \ No newline at end of file +guid: 5862c6a6d0a914f4d83224f8d039cf7b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta b/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta index 12fdb173..280381ca 100644 --- a/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta +++ b/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: f8514fd42f23cb641a36e52550825b35 \ No newline at end of file +guid: f8514fd42f23cb641a36e52550825b35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/Command.cs.meta b/UnityMcpBridge/Editor/Models/Command.cs.meta index 007b085f..63618f53 100644 --- a/UnityMcpBridge/Editor/Models/Command.cs.meta +++ b/UnityMcpBridge/Editor/Models/Command.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 6754c84e5deb74749bc3a19e0c9aa280 \ No newline at end of file +guid: 6754c84e5deb74749bc3a19e0c9aa280 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta b/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta index 4dad0b4b..0574c5a6 100644 --- a/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta +++ b/UnityMcpBridge/Editor/Models/MCPConfigServer.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 5fae9d995f514e9498e9613e2cdbeca9 \ No newline at end of file +guid: 5fae9d995f514e9498e9613e2cdbeca9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta b/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta index 9ef13109..1fb5f0b2 100644 --- a/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta +++ b/UnityMcpBridge/Editor/Models/MCPConfigServers.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: bcb583553e8173b49be71a5c43bd9502 \ No newline at end of file +guid: bcb583553e8173b49be71a5c43bd9502 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/McpClient.cs.meta b/UnityMcpBridge/Editor/Models/McpClient.cs.meta index a11df35e..b08dcf3b 100644 --- a/UnityMcpBridge/Editor/Models/McpClient.cs.meta +++ b/UnityMcpBridge/Editor/Models/McpClient.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: b1afa56984aec0d41808edcebf805e6a \ No newline at end of file +guid: b1afa56984aec0d41808edcebf805e6a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/McpConfig.cs.meta b/UnityMcpBridge/Editor/Models/McpConfig.cs.meta index 1f70925f..2a407c31 100644 --- a/UnityMcpBridge/Editor/Models/McpConfig.cs.meta +++ b/UnityMcpBridge/Editor/Models/McpConfig.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: c17c09908f0c1524daa8b6957ce1f7f5 \ No newline at end of file +guid: c17c09908f0c1524daa8b6957ce1f7f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/McpStatus.cs.meta b/UnityMcpBridge/Editor/Models/McpStatus.cs.meta index 4e5feb51..e8e930d3 100644 --- a/UnityMcpBridge/Editor/Models/McpStatus.cs.meta +++ b/UnityMcpBridge/Editor/Models/McpStatus.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: aa63057c9e5282d4887352578bf49971 \ No newline at end of file +guid: aa63057c9e5282d4887352578bf49971 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/McpTypes.cs.meta b/UnityMcpBridge/Editor/Models/McpTypes.cs.meta index d20128c2..377a6d0b 100644 --- a/UnityMcpBridge/Editor/Models/McpTypes.cs.meta +++ b/UnityMcpBridge/Editor/Models/McpTypes.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 9ca97c5ff5ed74c4fbb65cfa9d2bfed1 \ No newline at end of file +guid: 9ca97c5ff5ed74c4fbb65cfa9d2bfed1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta b/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta index 0c4b377e..6e675e9e 100644 --- a/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta +++ b/UnityMcpBridge/Editor/Models/ServerConfig.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: e4e45386fcc282249907c2e3c7e5d9c6 \ No newline at end of file +guid: e4e45386fcc282249907c2e3c7e5d9c6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta index 55b68298..15ec884b 100644 --- a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta +++ b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 5b61b5a84813b5749a5c64422694a0fa \ No newline at end of file +guid: 5b61b5a84813b5749a5c64422694a0fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta index b398ddf7..d9520d98 100644 --- a/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 896e8045986eb0d449ee68395479f1d6 \ No newline at end of file +guid: 896e8045986eb0d449ee68395479f1d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta b/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta index c4d71d4e..3dbc2e2f 100644 --- a/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ManageAsset.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: de90a1d9743a2874cb235cf0b83444b1 \ No newline at end of file +guid: de90a1d9743a2874cb235cf0b83444b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta b/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta index ed7502eb..8b55fb87 100644 --- a/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ManageEditor.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 43ac60aa36b361b4dbe4a038ae9f35c8 \ No newline at end of file +guid: 43ac60aa36b361b4dbe4a038ae9f35c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta index ec958a90..5093c861 100644 --- a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 7641d7388f0f6634b9d83d34de87b2ee \ No newline at end of file +guid: 7641d7388f0f6634b9d83d34de87b2ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta b/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta index 9fd63b34..532618aa 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ManageScene.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: b6ddda47f4077e74fbb5092388cefcc2 \ No newline at end of file +guid: b6ddda47f4077e74fbb5092388cefcc2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta b/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta index 171abb65..091cfe1c 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 626d2d44668019a45ae52e9ee066b7ec \ No newline at end of file +guid: 626d2d44668019a45ae52e9ee066b7ec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta b/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta index 98ef7171..039895f8 100644 --- a/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta +++ b/UnityMcpBridge/Editor/Tools/ReadConsole.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 46c4f3614ed61f547ba823f0b2790267 \ No newline at end of file +guid: 46c4f3614ed61f547ba823f0b2790267 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 4f3a6082..760a6082 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -21,6 +21,8 @@ public static partial class UnityMcpBridge private static TcpListener listener; private static bool isRunning = false; private static readonly object lockObj = new(); + private static readonly object startStopLock = new(); + private static bool initScheduled = false; private static Dictionary< string, (string commandJson, TaskCompletionSource tcs) @@ -81,75 +83,148 @@ public static bool FolderExists(string path) static UnityMcpBridge() { - Start(); + // Use delayed initialization to avoid repeated restarts during compilation + EditorApplication.delayCall += InitializeAfterCompilation; EditorApplication.quitting += Stop; + AssemblyReloadEvents.beforeAssemblyReload += Stop; // ensure listener releases before domain reload + + // Robust re-init hooks + UnityEditor.Compilation.CompilationPipeline.compilationFinished += _ => ScheduleInitRetry(); + EditorApplication.playModeStateChanged += state => + { + if (state == PlayModeStateChange.EnteredEditMode || state == PlayModeStateChange.EnteredPlayMode) + { + ScheduleInitRetry(); + } + }; } - public static void Start() + /// + /// Initialize the MCP bridge after Unity is fully loaded and compilation is complete. + /// This prevents repeated restarts during script compilation that cause port hopping. + /// + private static void InitializeAfterCompilation() { - Stop(); + initScheduled = false; - try + // Play-mode friendly: allow starting in play mode; only defer while compiling + if (EditorApplication.isCompiling) { - ServerInstaller.EnsureServerInstalled(); + ScheduleInitRetry(); + return; } - catch (Exception ex) + + if (!isRunning) { - Debug.LogError($"Failed to ensure UnityMcpServer is installed: {ex.Message}"); + Start(); + if (!isRunning) + { + // If a race prevented start, retry later + ScheduleInitRetry(); + } } + } - if (isRunning) + private static void ScheduleInitRetry() + { + if (initScheduled) { return; } + initScheduled = true; + EditorApplication.delayCall += InitializeAfterCompilation; + } - try - { - // Use PortManager to get available port with automatic fallback - currentUnityPort = PortManager.GetPortWithFallback(); - - listener = new TcpListener(IPAddress.Loopback, currentUnityPort); - listener.Start(); - isRunning = true; - isAutoConnectMode = false; // Normal startup mode - Debug.Log($"UnityMcpBridge started on port {currentUnityPort}."); - // Assuming ListenerLoop and ProcessCommands are defined elsewhere - Task.Run(ListenerLoop); - EditorApplication.update += ProcessCommands; - } - catch (SocketException ex) + public static void Start() + { + lock (startStopLock) { - if (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) + // Don't restart if already running on a working port + if (isRunning && listener != null) { - Debug.LogError( - $"Port {currentUnityPort} is already in use. This should not happen with dynamic port allocation." - ); + Debug.Log($"UnityMcpBridge already running on port {currentUnityPort}"); + return; } - else + + Stop(); + + // Removed automatic server installer; assume server exists inside the package (UPM). + + try { - Debug.LogError($"Failed to start TCP listener: {ex.Message}"); + // Try to reuse the current port if it's still available, otherwise get a new one + if (currentUnityPort > 0 && PortManager.IsPortAvailable(currentUnityPort)) + { + Debug.Log($"Reusing current port {currentUnityPort}"); + } + else + { + // Use PortManager to get available port with automatic fallback + currentUnityPort = PortManager.GetPortWithFallback(); + } + + listener = new TcpListener(IPAddress.Loopback, currentUnityPort); + listener.Start(); + isRunning = true; + isAutoConnectMode = false; // Normal startup mode + Debug.Log($"UnityMcpBridge started on port {currentUnityPort}."); + // Assuming ListenerLoop and ProcessCommands are defined elsewhere + Task.Run(ListenerLoop); + EditorApplication.update += ProcessCommands; + } + catch (SocketException ex) + { + if (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) + { + Debug.LogError( + $"Port {currentUnityPort} is already in use. Trying to find alternative..." + ); + + // Try once more with a fresh port discovery + try + { + currentUnityPort = PortManager.DiscoverNewPort(); + listener = new TcpListener(IPAddress.Loopback, currentUnityPort); + listener.Start(); + isRunning = true; + Debug.Log($"UnityMcpBridge started on fallback port {currentUnityPort}."); + Task.Run(ListenerLoop); + EditorApplication.update += ProcessCommands; + } + catch (Exception fallbackEx) + { + Debug.LogError($"Failed to start on fallback port: {fallbackEx.Message}"); + } + } + else + { + Debug.LogError($"Failed to start TCP listener: {ex.Message}"); + } } } } public static void Stop() { - if (!isRunning) + lock (startStopLock) { - return; - } + if (!isRunning) + { + return; + } - try - { - listener?.Stop(); - listener = null; - isRunning = false; - EditorApplication.update -= ProcessCommands; - Debug.Log("UnityMcpBridge stopped."); - } - catch (Exception ex) - { - Debug.LogError($"Error stopping UnityMcpBridge: {ex.Message}"); + try + { + listener?.Stop(); + listener = null; + isRunning = false; + EditorApplication.update -= ProcessCommands; + Debug.Log("UnityMcpBridge stopped."); + } + catch (Exception ex) + { + Debug.LogError($"Error stopping UnityMcpBridge: {ex.Message}"); + } } } diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs.meta b/UnityMcpBridge/Editor/UnityMcpBridge.cs.meta index 39156984..dcaa7616 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs.meta +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 1e0fb0e418dd19345a8236c44078972b \ No newline at end of file +guid: 1e0fb0e418dd19345a8236c44078972b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta index b5797cc2..41646e62 100644 --- a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta +++ b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 36798bd7b867b8e43ac86885e94f928f \ No newline at end of file +guid: 36798bd7b867b8e43ac86885e94f928f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Windows/UnityMCPEditorWindow.cs.meta b/UnityMcpBridge/Editor/Windows/UnityMCPEditorWindow.cs.meta index 0229c757..c492a9d6 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMCPEditorWindow.cs.meta +++ b/UnityMcpBridge/Editor/Windows/UnityMCPEditorWindow.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 4283e255b343c4546b843cd22214ac93 \ No newline at end of file +guid: 4283e255b343c4546b843cd22214ac93 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 14c9f5b6..691ef452 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -58,6 +58,8 @@ private void OnEnable() private void OnFocus() { + // Refresh bridge running state on focus in case initialization completed after domain reload + isUnityBridgeRunning = UnityMcpBridge.IsRunning; if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) { McpClient selectedClient = mcpClients.clients[selectedClientIndex]; @@ -255,6 +257,9 @@ private void DrawBridgeSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); + // Always reflect the live state each repaint to avoid stale UI after recompiles + isUnityBridgeRunning = UnityMcpBridge.IsRunning; + GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 @@ -458,8 +463,9 @@ private void ToggleUnityBridge() { UnityMcpBridge.Start(); } - - isUnityBridgeRunning = !isUnityBridgeRunning; + // Reflect the actual state post-operation (avoid optimistic toggle) + isUnityBridgeRunning = UnityMcpBridge.IsRunning; + Repaint(); } private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) diff --git a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta index 437ccab6..fb13126b 100644 --- a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta +++ b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 377fe73d52cf0435fabead5f50a0d204 \ No newline at end of file +guid: 377fe73d52cf0435fabead5f50a0d204 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta b/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta index 9596160f..caaf2859 100644 --- a/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta +++ b/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: e65311c160f0d41d4a1b45a3dba8dd5a \ No newline at end of file +guid: e65311c160f0d41d4a1b45a3dba8dd5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpServer/src/port_discovery.py b/UnityMcpServer/src/port_discovery.py index a0dfe961..c09efe32 100644 --- a/UnityMcpServer/src/port_discovery.py +++ b/UnityMcpServer/src/port_discovery.py @@ -1,69 +1,133 @@ """ Port discovery utility for Unity MCP Server. -Reads port configuration saved by Unity Bridge. + +What changed and why: +- Unity now writes a per-project port file named like + `~/.unity-mcp/unity-mcp-port-.json` to avoid projects overwriting + each other's saved port. The legacy file `unity-mcp-port.json` may still + exist. +- This module now scans for both patterns, prefers the most recently + modified file, and verifies that the port is actually a Unity MCP listener + (quick socket connect + ping) before choosing it. """ import json import os import logging from pathlib import Path -from typing import Optional +from typing import Optional, List +import glob +import socket logger = logging.getLogger("unity-mcp-server") class PortDiscovery: """Handles port discovery from Unity Bridge registry""" - - REGISTRY_FILE = "unity-mcp-port.json" + REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file + DEFAULT_PORT = 6400 + CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery @staticmethod def get_registry_path() -> Path: """Get the path to the port registry file""" return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE + @staticmethod + def get_registry_dir() -> Path: + return Path.home() / ".unity-mcp" + + @staticmethod + def list_candidate_files() -> List[Path]: + """Return candidate registry files, newest first. + Includes hashed per-project files and the legacy file (if present). + """ + base = PortDiscovery.get_registry_dir() + hashed = sorted( + (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + legacy = PortDiscovery.get_registry_path() + if legacy.exists(): + # Put legacy at the end so hashed, per-project files win + hashed.append(legacy) + return hashed + + @staticmethod + def _try_probe_unity_mcp(port: int) -> bool: + """Quickly check if a Unity MCP listener is on this port. + Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. + """ + try: + with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: + s.settimeout(PortDiscovery.CONNECT_TIMEOUT) + try: + s.sendall(b"ping") + data = s.recv(512) + # Minimal validation: look for a success pong response + if data and b'"message":"pong"' in data: + return True + except Exception: + # Even if the ping fails, a successful TCP connect is a strong signal. + # Fall back to treating the port as viable if connect succeeded. + return True + except Exception: + return False + return False + @staticmethod def discover_unity_port() -> int: """ - Discover Unity port from registry file with fallback to default + Discover Unity port by scanning per-project and legacy registry files. + Prefer the newest file whose port responds; fall back to first parsed + value; finally default to 6400. Returns: Port number to connect to """ - registry_file = PortDiscovery.get_registry_path() - - if registry_file.exists(): + candidates = PortDiscovery.list_candidate_files() + + first_seen_port: Optional[int] = None + + for path in candidates: try: - with open(registry_file, 'r') as f: - port_config = json.load(f) - - unity_port = port_config.get('unity_port') - if unity_port and isinstance(unity_port, int): - logger.info(f"Discovered Unity port from registry: {unity_port}") - return unity_port - + with open(path, 'r') as f: + cfg = json.load(f) + unity_port = cfg.get('unity_port') + if isinstance(unity_port, int): + if first_seen_port is None: + first_seen_port = unity_port + if PortDiscovery._try_probe_unity_mcp(unity_port): + logger.info(f"Using Unity port from {path.name}: {unity_port}") + return unity_port except Exception as e: - logger.warning(f"Could not read port registry: {e}") - + logger.warning(f"Could not read port registry {path}: {e}") + + if first_seen_port is not None: + logger.info(f"No responsive port found; using first seen value {first_seen_port}") + return first_seen_port + # Fallback to default port - logger.info("No port registry found, using default port 6400") - return 6400 + logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") + return PortDiscovery.DEFAULT_PORT @staticmethod def get_port_config() -> Optional[dict]: """ - Get the full port configuration from registry + Get the most relevant port configuration from registry. + Returns the most recent hashed file's config if present, + otherwise the legacy file's config. Returns None if nothing exists. Returns: Port configuration dict or None if not found """ - registry_file = PortDiscovery.get_registry_path() - - if not registry_file.exists(): + candidates = PortDiscovery.list_candidate_files() + if not candidates: return None - - try: - with open(registry_file, 'r') as f: - return json.load(f) - except Exception as e: - logger.warning(f"Could not read port configuration: {e}") - return None \ No newline at end of file + for path in candidates: + try: + with open(path, 'r') as f: + return json.load(f) + except Exception as e: + logger.warning(f"Could not read port configuration {path}: {e}") + return None \ No newline at end of file From 5c4ea29fc735280809811e4b5211888517a0b2b6 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 7 Aug 2025 17:43:33 -0700 Subject: [PATCH 07/69] Editor Window: streamline layout, remove redundant badges; move and rename auto-run toggle to client section ("Auto-connect to MCP Clients"); rename button to "Run Client Setup"; fix dev-mode status by using FindPackagePythonDirectory() for Claude/Desktop path checks --- .../Editor/Windows/UnityMcpEditorWindow.cs | 387 ++++++++++++++++-- 1 file changed, 343 insertions(+), 44 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 691ef452..7dc485b3 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1,6 +1,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Net.Sockets; +using System.Net; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -21,6 +25,10 @@ public class UnityMcpEditorWindow : EditorWindow private Color pythonServerInstallationStatusColor = Color.red; private const int mcpPort = 6500; // MCP port (still hardcoded for MCP server) private readonly McpClients mcpClients = new(); + private bool autoRegisterEnabled; + private bool lastClientRegisteredOk; + private bool lastBridgeVerifiedOk; + private string pythonDirOverride = null; // Script validation settings private int validationLevelIndex = 1; // Default to Standard @@ -47,6 +55,7 @@ private void OnEnable() // Refresh bridge status isUnityBridgeRunning = UnityMcpBridge.IsRunning; + autoRegisterEnabled = EditorPrefs.GetBool("UnityMCP.AutoRegisterEnabled", true); foreach (McpClient mcpClient in mcpClients.clients) { CheckMcpConfiguration(mcpClient); @@ -54,6 +63,12 @@ private void OnEnable() // Load validation level setting LoadValidationLevelSetting(); + + // First-run auto-setup (register client(s) and ensure bridge is listening) + if (autoRegisterEnabled) + { + AutoFirstRunSetup(); + } } private void OnFocus() @@ -144,27 +159,14 @@ private void OnGUI() // Header DrawHeader(); - // Main sections in a more compact layout - EditorGUILayout.BeginHorizontal(); - - // Left column - Status and Bridge - EditorGUILayout.BeginVertical(GUILayout.Width(position.width * 0.5f)); + // Single-column streamlined layout DrawServerStatusSection(); - EditorGUILayout.Space(5); + EditorGUILayout.Space(6); DrawBridgeSection(); - EditorGUILayout.EndVertical(); - - // Right column - Validation Settings - EditorGUILayout.BeginVertical(); - DrawValidationSection(); - EditorGUILayout.EndVertical(); - - EditorGUILayout.EndHorizontal(); - EditorGUILayout.Space(10); - - // Unified MCP Client Configuration DrawUnifiedClientConfiguration(); + EditorGUILayout.Space(10); + DrawValidationSection(); EditorGUILayout.EndScrollView(); } @@ -214,32 +216,21 @@ private void DrawServerStatusSection() EditorGUILayout.Space(5); - // Connection mode and Auto-Connect button + // Connection mode and Setup controls EditorGUILayout.BeginHorizontal(); - + bool isAutoMode = UnityMcpBridge.IsAutoConnectMode(); GUIStyle modeStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; EditorGUILayout.LabelField($"Mode: {(isAutoMode ? "Auto" : "Standard")}", modeStyle); - - // Auto-Connect button - if (GUILayout.Button(isAutoMode ? "Connected ✓" : "Auto-Connect", GUILayout.Width(100), GUILayout.Height(24))) + + GUILayout.FlexibleSpace(); + + // Run Client Setup button + if (GUILayout.Button("Re-Run Client Setup", GUILayout.Width(140), GUILayout.Height(24))) { - if (!isAutoMode) - { - try - { - UnityMcpBridge.StartAutoConnect(); - // Update UI state - isUnityBridgeRunning = UnityMcpBridge.IsRunning; - Repaint(); - } - catch (Exception ex) - { - EditorUtility.DisplayDialog("Auto-Connect Failed", ex.Message, "OK"); - } - } + RunSetupNow(); } - + EditorGUILayout.EndHorizontal(); // Current ports display @@ -250,6 +241,34 @@ private void DrawServerStatusSection() }; EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle); EditorGUILayout.Space(5); + + // Removed redundant inline badges to streamline UI + + // Troubleshooting helpers + if (pythonServerInstallationStatusColor != Color.green) + { + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Select server folder…", GUILayout.Width(160))) + { + string picked = EditorUtility.OpenFolderPanel("Select UnityMcpServer/src", Application.dataPath, ""); + if (!string.IsNullOrEmpty(picked) && File.Exists(Path.Combine(picked, "server.py"))) + { + pythonDirOverride = picked; + EditorPrefs.SetString("UnityMCP.PythonDirOverride", pythonDirOverride); + UpdatePythonServerInstallationStatus(); + } + else if (!string.IsNullOrEmpty(picked)) + { + EditorUtility.DisplayDialog("Invalid Selection", "The selected folder does not contain server.py", "OK"); + } + } + if (GUILayout.Button("Verify again", GUILayout.Width(120))) + { + UpdatePythonServerInstallationStatus(); + } + } + } EditorGUILayout.EndVertical(); } @@ -325,6 +344,15 @@ private void DrawUnifiedClientConfiguration() EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle); EditorGUILayout.Space(10); + // Auto-connect toggle (moved from Server Status) + bool newAuto = EditorGUILayout.ToggleLeft("Auto-connect to MCP Clients", autoRegisterEnabled); + if (newAuto != autoRegisterEnabled) + { + autoRegisterEnabled = newAuto; + EditorPrefs.SetBool("UnityMCP.AutoRegisterEnabled", autoRegisterEnabled); + } + EditorGUILayout.Space(6); + // Client selector string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray(); EditorGUI.BeginChangeCheck(); @@ -346,6 +374,222 @@ private void DrawUnifiedClientConfiguration() EditorGUILayout.EndVertical(); } + private void AutoFirstRunSetup() + { + try + { + // Project-scoped one-time flag + string projectPath = Application.dataPath ?? string.Empty; + string key = $"UnityMCP.AutoRegistered.{ComputeSha1(projectPath)}"; + if (EditorPrefs.GetBool(key, false)) + { + return; + } + + // Attempt client registration using discovered Python server dir + pythonDirOverride ??= EditorPrefs.GetString("UnityMCP.PythonDirOverride", null); + string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); + if (!string.IsNullOrEmpty(pythonDir) && File.Exists(Path.Combine(pythonDir, "server.py"))) + { + bool anyRegistered = false; + foreach (McpClient client in mcpClients.clients) + { + try + { + if (client.mcpType == McpTypes.ClaudeCode) + { + if (!IsClaudeConfigured()) + { + RegisterWithClaudeCode(pythonDir); + anyRegistered = true; + } + } + else + { + // For Cursor/others, skip if already configured + if (!IsCursorConfigured(pythonDir)) + { + ConfigureMcpClient(client); + anyRegistered = true; + } + } + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Auto-setup client '{client.name}' failed: {ex.Message}"); + } + } + lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); + } + + // Ensure the bridge is listening and has a fresh saved port + if (!UnityMcpBridge.IsRunning) + { + try + { + UnityMcpBridge.StartAutoConnect(); + isUnityBridgeRunning = UnityMcpBridge.IsRunning; + Repaint(); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Auto-setup StartAutoConnect failed: {ex.Message}"); + } + } + + // Verify bridge with a quick ping + lastBridgeVerifiedOk = VerifyBridgePing(UnityMcpBridge.GetCurrentPort()); + + EditorPrefs.SetBool(key, true); + } + catch (Exception e) + { + UnityEngine.Debug.LogWarning($"Unity MCP auto-setup skipped: {e.Message}"); + } + } + + private static string ComputeSha1(string input) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hash = sha1.ComputeHash(bytes); + StringBuilder sb = new StringBuilder(hash.Length * 2); + foreach (byte b in hash) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString(); + } + catch + { + return ""; + } + } + + private void RunSetupNow() + { + // Force a one-shot setup regardless of first-run flag + try + { + pythonDirOverride ??= EditorPrefs.GetString("UnityMCP.PythonDirOverride", null); + string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); + if (string.IsNullOrEmpty(pythonDir) || !File.Exists(Path.Combine(pythonDir, "server.py"))) + { + EditorUtility.DisplayDialog("Setup", "Python server not found. Please select UnityMcpServer/src.", "OK"); + return; + } + + bool anyRegistered = false; + foreach (McpClient client in mcpClients.clients) + { + try + { + if (client.mcpType == McpTypes.ClaudeCode) + { + if (!IsClaudeConfigured()) + { + RegisterWithClaudeCode(pythonDir); + anyRegistered = true; + } + } + else + { + if (!IsCursorConfigured(pythonDir)) + { + ConfigureMcpClient(client); + anyRegistered = true; + } + } + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); + } + } + lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); + + // Restart/ensure bridge + UnityMcpBridge.StartAutoConnect(); + isUnityBridgeRunning = UnityMcpBridge.IsRunning; + + // Verify + lastBridgeVerifiedOk = VerifyBridgePing(UnityMcpBridge.GetCurrentPort()); + Repaint(); + } + catch (Exception e) + { + EditorUtility.DisplayDialog("Setup Failed", e.Message, "OK"); + } + } + + private static bool IsCursorConfigured(string pythonDir) + { + try + { + string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cursor", "mcp.json") + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cursor", "mcp.json"); + if (!File.Exists(configPath)) return false; + string json = File.ReadAllText(configPath); + dynamic cfg = JsonConvert.DeserializeObject(json); + var servers = cfg?.mcpServers; + if (servers == null) return false; + var unity = servers.unityMCP ?? servers.UnityMCP; + if (unity == null) return false; + var args = unity.args; + if (args == null) return false; + foreach (var a in args) + { + string s = (string)a; + if (!string.IsNullOrEmpty(s) && s.Contains(pythonDir, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + catch { return false; } + } + + private static bool IsClaudeConfigured() + { + try + { + string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "claude" : "/usr/local/bin/claude"; + var psi = new ProcessStartInfo { FileName = command, Arguments = "mcp list", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; + using var p = Process.Start(psi); + string output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(3000); + if (p.ExitCode != 0) return false; + return output.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0; + } + catch { return false; } + } + + private static bool VerifyBridgePing(int port) + { + try + { + using TcpClient c = new TcpClient(); + var task = c.ConnectAsync(IPAddress.Loopback, port); + if (!task.Wait(500)) return false; + using NetworkStream s = c.GetStream(); + byte[] ping = Encoding.UTF8.GetBytes("ping"); + s.Write(ping, 0, ping.Length); + s.ReadTimeout = 1000; + byte[] buf = new byte[256]; + int n = s.Read(buf, 0, buf.Length); + if (n <= 0) return false; + string resp = Encoding.UTF8.GetString(buf, 0, n); + return resp.Contains("pong", StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + private void DrawClientConfigurationCompact(McpClient mcpClient) { // Status display @@ -623,6 +867,26 @@ private string FindPackagePythonDirectory() try { + // Only check dev paths if we're using a file-based package (development mode) + bool isDevelopmentMode = IsDevelopmentMode(); + if (isDevelopmentMode) + { + string currentPackagePath = Path.GetDirectoryName(Application.dataPath); + string[] devPaths = { + Path.Combine(currentPackagePath, "unity-mcp", "UnityMcpServer", "src"), + Path.Combine(Path.GetDirectoryName(currentPackagePath), "unity-mcp", "UnityMcpServer", "src"), + }; + + foreach (string devPath in devPaths) + { + if (Directory.Exists(devPath) && File.Exists(Path.Combine(devPath, "server.py"))) + { + UnityEngine.Debug.Log($"Currently in development mode. Package: {devPath}"); + return devPath; + } + } + } + // Try to find the package using Package Manager API UnityEditor.PackageManager.Requests.ListRequest request = UnityEditor.PackageManager.Client.List(); @@ -661,10 +925,6 @@ private string FindPackagePythonDirectory() // Check for local development structure string[] possibleDirs = { - // Check in the Unity project's Packages folder (for local package development) - Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Packages", "unity-mcp", "UnityMcpServer", "src")), - // Check relative to the Unity project (for development) - Path.GetFullPath(Path.Combine(Application.dataPath, "..", "unity-mcp", "UnityMcpServer", "src")), // Check in user's home directory (common installation location) Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "unity-mcp", "UnityMcpServer", "src"), // Check in Applications folder (macOS/Linux common location) @@ -692,6 +952,44 @@ private string FindPackagePythonDirectory() return pythonDir; } + private bool IsDevelopmentMode() + { + try + { + // Check if we're using a file-based package by looking at the manifest + string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); + if (File.Exists(manifestPath)) + { + string manifestContent = File.ReadAllText(manifestPath); + // Look for file-based package reference + if (manifestContent.Contains("file:/") && manifestContent.Contains("unity-mcp")) + { + return true; + } + } + + // Also check if we're in a development environment by looking for common dev paths + string[] devIndicators = { + Path.Combine(Application.dataPath, "..", "unity-mcp"), + Path.Combine(Application.dataPath, "..", "..", "unity-mcp"), + }; + + foreach (string indicator in devIndicators) + { + if (Directory.Exists(indicator) && File.Exists(Path.Combine(indicator, "UnityMcpServer", "src", "server.py"))) + { + return true; + } + } + + return false; + } + catch + { + return false; + } + } + private string ConfigureMcpClient(McpClient mcpClient) { try @@ -718,8 +1016,8 @@ private string ConfigureMcpClient(McpClient mcpClient) // Create directory if it doesn't exist Directory.CreateDirectory(Path.GetDirectoryName(configPath)); - // Find the server.py file location - string pythonDir = ServerInstaller.GetServerPath(); + // Find the server.py file location using the same logic as FindPackagePythonDirectory + string pythonDir = FindPackagePythonDirectory(); if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) { @@ -877,7 +1175,8 @@ private void CheckMcpConfiguration(McpClient mcpClient) } string configJson = File.ReadAllText(configPath); - string pythonDir = ServerInstaller.GetServerPath(); + // Use the same path resolution as configuration to avoid false "Incorrect Path" in dev mode + string pythonDir = FindPackagePythonDirectory(); // Use switch statement to handle different client types, extracting common logic string[] args = null; From 673bc1bd497b293e8528872725315960d76f35c9 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 7 Aug 2025 19:06:12 -0700 Subject: [PATCH 08/69] Add PackageInstaller for automatic Python server installation on first package load --- .../Editor/Helpers/PackageInstaller.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 UnityMcpBridge/Editor/Helpers/PackageInstaller.cs diff --git a/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs new file mode 100644 index 00000000..75cdb3b9 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs @@ -0,0 +1,43 @@ +using UnityEditor; +using UnityEngine; + +namespace UnityMcpBridge.Editor.Helpers +{ + /// + /// Handles automatic installation of the Python server when the package is first installed. + /// + [InitializeOnLoad] + public static class PackageInstaller + { + private const string InstallationFlagKey = "UnityMCP.ServerInstalled"; + + static PackageInstaller() + { + // Check if this is the first time the package is loaded + if (!EditorPrefs.GetBool(InstallationFlagKey, false)) + { + // Schedule the installation for after Unity is fully loaded + EditorApplication.delayCall += InstallServerOnFirstLoad; + } + } + + private static void InstallServerOnFirstLoad() + { + try + { + Debug.Log("Unity MCP: Installing Python server..."); + ServerInstaller.EnsureServerInstalled(); + + // Mark as installed + EditorPrefs.SetBool(InstallationFlagKey, true); + + Debug.Log("Unity MCP: Python server installation completed successfully."); + } + catch (System.Exception ex) + { + Debug.LogError($"Unity MCP: Failed to install Python server: {ex.Message}"); + Debug.LogWarning("Unity MCP: You may need to manually install the Python server. Check the Unity MCP Editor Window for instructions."); + } + } + } +} From a0fd9199bbe57fe2914c34488791eceb9c6d2af5 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 7 Aug 2025 19:09:52 -0700 Subject: [PATCH 09/69] Add meta for PackageInstaller so Unity includes it in package cache --- .../Editor/Helpers/PackageInstaller.cs.meta | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 UnityMcpBridge/Editor/Helpers/PackageInstaller.cs.meta diff --git a/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs.meta b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs.meta new file mode 100644 index 00000000..156e75fb --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 19e6eaa637484e9fa19f9a0459809de2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From a65f10383ab5ec381adcbb6f8167f9e5c9d3a385 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 8 Aug 2025 08:08:30 -0700 Subject: [PATCH 10/69] feat(bridge): embed Python server into package and remove Git-based installer - Switch ServerInstaller to embedded copy-only (no network) - Simplify Editor UI server status to 'Installed (Embedded)' - Vendor UnityMcpServer/src into UnityMcpBridge/UnityMcpServer/src for UPM distribution - Keep bridge recompile robustness (heartbeat + sticky port) --- .../Editor/Helpers/ServerInstaller.cs | 227 +++++------- UnityMcpBridge/Editor/UnityMcpBridge.cs | 184 ++++++--- .../Editor/Windows/UnityMcpEditorWindow.cs | 15 +- UnityMcpBridge/UnityMcpServer/src.meta | 8 + .../UnityMcpServer/src/.python-version | 1 + UnityMcpBridge/UnityMcpServer/src/Dockerfile | 27 ++ .../UnityMcpServer/src/Dockerfile.meta | 7 + .../src/UnityMcpServer.egg-info.meta | 8 + UnityMcpBridge/UnityMcpServer/src/__init__.py | 3 + .../UnityMcpServer/src/__init__.py.meta | 7 + UnityMcpBridge/UnityMcpServer/src/config.py | 30 ++ .../UnityMcpServer/src/config.py.meta | 7 + .../UnityMcpServer/src/port_discovery.py | 155 ++++++++ .../UnityMcpServer/src/port_discovery.py.meta | 7 + .../UnityMcpServer/src/pyproject.toml | 15 + .../UnityMcpServer/src/pyproject.toml.meta | 7 + UnityMcpBridge/UnityMcpServer/src/server.py | 73 ++++ .../UnityMcpServer/src/server.py.meta | 7 + UnityMcpBridge/UnityMcpServer/src/tools.meta | 8 + .../UnityMcpServer/src/tools/__init__.py | 21 ++ .../UnityMcpServer/src/tools/__init__.py.meta | 7 + .../src/tools/execute_menu_item.py | 51 +++ .../src/tools/execute_menu_item.py.meta | 7 + .../UnityMcpServer/src/tools/manage_asset.py | 83 +++++ .../src/tools/manage_asset.py.meta | 7 + .../UnityMcpServer/src/tools/manage_editor.py | 53 +++ .../src/tools/manage_editor.py.meta | 7 + .../src/tools/manage_gameobject.py | 138 +++++++ .../src/tools/manage_gameobject.py.meta | 7 + .../UnityMcpServer/src/tools/manage_scene.py | 47 +++ .../src/tools/manage_scene.py.meta | 7 + .../UnityMcpServer/src/tools/manage_script.py | 74 ++++ .../src/tools/manage_script.py.meta | 7 + .../UnityMcpServer/src/tools/manage_shader.py | 67 ++++ .../src/tools/manage_shader.py.meta | 7 + .../UnityMcpServer/src/tools/read_console.py | 70 ++++ .../src/tools/read_console.py.meta | 7 + .../UnityMcpServer/src/unity_connection.py | 239 ++++++++++++ .../src/unity_connection.py.meta | 7 + UnityMcpBridge/UnityMcpServer/src/uv.lock | 349 ++++++++++++++++++ .../UnityMcpServer/src/uv.lock.meta | 7 + UnityMcpServer/src/config.py | 6 +- UnityMcpServer/src/port_discovery.py | 28 +- UnityMcpServer/src/unity_connection.py | 142 ++++--- 44 files changed, 1983 insertions(+), 258 deletions(-) create mode 100644 UnityMcpBridge/UnityMcpServer/src.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/.python-version create mode 100644 UnityMcpBridge/UnityMcpServer/src/Dockerfile create mode 100644 UnityMcpBridge/UnityMcpServer/src/Dockerfile.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/UnityMcpServer.egg-info.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/__init__.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/__init__.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/config.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/config.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/port_discovery.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/port_discovery.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/pyproject.toml create mode 100644 UnityMcpBridge/UnityMcpServer/src/pyproject.toml.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/server.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/server.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/__init__.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/__init__.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/read_console.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/read_console.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/unity_connection.py create mode 100644 UnityMcpBridge/UnityMcpServer/src/unity_connection.py.meta create mode 100644 UnityMcpBridge/UnityMcpServer/src/uv.lock create mode 100644 UnityMcpBridge/UnityMcpServer/src/uv.lock.meta diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 9d5682f4..56b23430 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -1,8 +1,7 @@ using System; using System.IO; -using System.Linq; -using System.Net; using System.Runtime.InteropServices; +using UnityEditor; using UnityEngine; namespace UnityMcpBridge.Editor.Helpers @@ -11,37 +10,34 @@ public static class ServerInstaller { private const string RootFolder = "UnityMCP"; private const string ServerFolder = "UnityMcpServer"; - private const string BranchName = "master"; - private const string GitUrl = "https://github.com/justinpbarnett/unity-mcp.git"; - private const string PyprojectUrl = - "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/refs/heads/" - + BranchName - + "/UnityMcpServer/src/pyproject.toml"; - /// - /// Ensures the unity-mcp-server is installed and up to date. + /// Ensures the unity-mcp-server is installed locally by copying from the embedded package source. + /// No network calls or Git operations are performed. /// public static void EnsureServerInstalled() { try { string saveLocation = GetSaveLocation(); + string destRoot = Path.Combine(saveLocation, ServerFolder); + string destSrc = Path.Combine(destRoot, "src"); - if (!IsServerInstalled(saveLocation)) + if (File.Exists(Path.Combine(destSrc, "server.py"))) { - InstallServer(saveLocation); + return; // Already installed } - else - { - string installedVersion = GetInstalledVersion(); - string latestVersion = GetLatestVersion(); - if (IsNewerVersion(latestVersion, installedVersion)) - { - UpdateServer(saveLocation); - } - else { } + if (!TryGetEmbeddedServerSource(out string embeddedSrc)) + { + throw new Exception("Could not find embedded UnityMcpServer/src in the package."); } + + // Ensure destination exists + Directory.CreateDirectory(destRoot); + + // Copy the entire UnityMcpServer folder (parent of src) + string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer + CopyDirectoryRecursive(embeddedRoot, destRoot); } catch (Exception ex) { @@ -111,139 +107,110 @@ private static bool IsDirectoryWritable(string path) private static bool IsServerInstalled(string location) { return Directory.Exists(location) - && File.Exists(Path.Combine(location, ServerFolder, "src", "pyproject.toml")); - } - - /// - /// Installs the server by cloning only the UnityMcpServer folder from the repository and setting up dependencies. - /// - private static void InstallServer(string location) - { - // Create the src directory where the server code will reside - Directory.CreateDirectory(location); - - // Initialize git repo in the src directory - RunCommand("git", $"init", workingDirectory: location); - - // Add remote - RunCommand("git", $"remote add origin {GitUrl}", workingDirectory: location); - - // Configure sparse checkout - RunCommand("git", "config core.sparseCheckout true", workingDirectory: location); - - // Set sparse checkout path to only include UnityMcpServer folder - string sparseCheckoutPath = Path.Combine(location, ".git", "info", "sparse-checkout"); - File.WriteAllText(sparseCheckoutPath, $"{ServerFolder}/"); - - // Fetch and checkout the branch - RunCommand("git", $"fetch --depth=1 origin {BranchName}", workingDirectory: location); - RunCommand("git", $"checkout {BranchName}", workingDirectory: location); - } - - /// - /// Fetches the currently installed version from the local pyproject.toml file. - /// - public static string GetInstalledVersion() - { - string pyprojectPath = Path.Combine( - GetSaveLocation(), - ServerFolder, - "src", - "pyproject.toml" - ); - return ParseVersionFromPyproject(File.ReadAllText(pyprojectPath)); + && File.Exists(Path.Combine(location, ServerFolder, "src", "server.py")); } /// - /// Fetches the latest version from the GitHub pyproject.toml file. + /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package + /// or common development locations. /// - public static string GetLatestVersion() + private static bool TryGetEmbeddedServerSource(out string srcPath) { - using WebClient webClient = new(); - string pyprojectContent = webClient.DownloadString(PyprojectUrl); - return ParseVersionFromPyproject(pyprojectContent); - } - - /// - /// Updates the server by pulling the latest changes for the UnityMcpServer folder only. - /// - private static void UpdateServer(string location) - { - RunCommand("git", $"pull origin {BranchName}", workingDirectory: location); - } - - /// - /// Parses the version number from pyproject.toml content. - /// - private static string ParseVersionFromPyproject(string content) - { - foreach (string line in content.Split('\n')) + // 1) Development mode: common repo layouts + try { - if (line.Trim().StartsWith("version =")) + string projectRoot = Path.GetDirectoryName(Application.dataPath); + string[] devCandidates = + { + Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), + Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), + }; + foreach (string candidate in devCandidates) { - string[] parts = line.Split('='); - if (parts.Length == 2) + string full = Path.GetFullPath(candidate); + if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) { - return parts[1].Trim().Trim('"'); + srcPath = full; + return true; } } } - throw new Exception("Version not found in pyproject.toml"); - } + catch { /* ignore */ } - /// - /// Compares two version strings to determine if the latest is newer. - /// - public static bool IsNewerVersion(string latest, string installed) - { - int[] latestParts = latest.Split('.').Select(int.Parse).ToArray(); - int[] installedParts = installed.Split('.').Select(int.Parse).ToArray(); - for (int i = 0; i < Math.Min(latestParts.Length, installedParts.Length); i++) + // 2) Installed package: resolve via Package Manager + try { - if (latestParts[i] > installedParts[i]) + var list = UnityEditor.PackageManager.Client.List(); + while (!list.IsCompleted) { } + if (list.Status == UnityEditor.PackageManager.StatusCode.Success) { - return true; + foreach (var pkg in list.Result) + { + if (pkg.name == "com.justinpbarnett.unity-mcp") + { + string packagePath = pkg.resolvedPath; // e.g., Library/PackageCache/... or local path + + // Preferred: UnityMcpServer embedded alongside Editor/Runtime within the package + string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); + if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) + { + srcPath = embedded; + return true; + } + + // Legacy: sibling of the package folder (dev-linked). Only valid when present on disk. + string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); + if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) + { + srcPath = sibling; + return true; + } + } + } } + } + catch { /* ignore */ } - if (latestParts[i] < installedParts[i]) + // 3) Fallback to previous common install locations + try + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string[] candidates = { - return false; + Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), + Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), + }; + foreach (string candidate in candidates) + { + if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) + { + srcPath = candidate; + return true; + } } } - return latestParts.Length > installedParts.Length; + catch { /* ignore */ } + + srcPath = null; + return false; } - /// - /// Runs a command-line process and handles output/errors. - /// - private static void RunCommand( - string command, - string arguments, - string workingDirectory = null - ) + private static void CopyDirectoryRecursive(string sourceDir, string destinationDir) { - System.Diagnostics.Process process = new() + Directory.CreateDirectory(destinationDir); + + foreach (string filePath in Directory.GetFiles(sourceDir)) { - StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = command, - Arguments = arguments, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = workingDirectory ?? string.Empty, - }, - }; - process.Start(); - string output = process.StandardOutput.ReadToEnd(); - string error = process.StandardError.ReadToEnd(); - process.WaitForExit(); - if (process.ExitCode != 0) + string fileName = Path.GetFileName(filePath); + string destFile = Path.Combine(destinationDir, fileName); + File.Copy(filePath, destFile, overwrite: true); + } + + foreach (string dirPath in Directory.GetDirectories(sourceDir)) { - throw new Exception( - $"Command failed: {command} {arguments}\nOutput: {output}\nError: {error}" - ); + string dirName = Path.GetFileName(dirPath); + string destSubDir = Path.Combine(destinationDir, dirName); + CopyDirectoryRecursive(dirPath, destSubDir); } } } diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 760a6082..a55a7ec4 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using System.Net.Sockets; +using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -23,6 +24,7 @@ public static partial class UnityMcpBridge private static readonly object lockObj = new(); private static readonly object startStopLock = new(); private static bool initScheduled = false; + private static double nextHeartbeatAt = 0.0f; private static Dictionary< string, (string commandJson, TaskCompletionSource tcs) @@ -83,20 +85,11 @@ public static bool FolderExists(string path) static UnityMcpBridge() { - // Use delayed initialization to avoid repeated restarts during compilation - EditorApplication.delayCall += InitializeAfterCompilation; + // Immediate start for minimal downtime, plus quit hook + Start(); EditorApplication.quitting += Stop; - AssemblyReloadEvents.beforeAssemblyReload += Stop; // ensure listener releases before domain reload - - // Robust re-init hooks - UnityEditor.Compilation.CompilationPipeline.compilationFinished += _ => ScheduleInitRetry(); - EditorApplication.playModeStateChanged += state => - { - if (state == PlayModeStateChange.EnteredEditMode || state == PlayModeStateChange.EnteredPlayMode) - { - ScheduleInitRetry(); - } - }; + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } /// @@ -148,58 +141,77 @@ public static void Start() Stop(); - // Removed automatic server installer; assume server exists inside the package (UPM). - + // Attempt fast bind with same-port preference try { - // Try to reuse the current port if it's still available, otherwise get a new one - if (currentUnityPort > 0 && PortManager.IsPortAvailable(currentUnityPort)) - { - Debug.Log($"Reusing current port {currentUnityPort}"); - } - else - { - // Use PortManager to get available port with automatic fallback - currentUnityPort = PortManager.GetPortWithFallback(); - } - - listener = new TcpListener(IPAddress.Loopback, currentUnityPort); - listener.Start(); - isRunning = true; - isAutoConnectMode = false; // Normal startup mode - Debug.Log($"UnityMcpBridge started on port {currentUnityPort}."); - // Assuming ListenerLoop and ProcessCommands are defined elsewhere - Task.Run(ListenerLoop); - EditorApplication.update += ProcessCommands; - } - catch (SocketException ex) - { - if (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) + currentUnityPort = currentUnityPort > 0 && PortManager.IsPortAvailable(currentUnityPort) + ? currentUnityPort + : PortManager.GetPortWithFallback(); + + const int maxImmediateRetries = 3; + const int retrySleepMs = 75; + int attempt = 0; + for (;;) { - Debug.LogError( - $"Port {currentUnityPort} is already in use. Trying to find alternative..." - ); - - // Try once more with a fresh port discovery try { - currentUnityPort = PortManager.DiscoverNewPort(); listener = new TcpListener(IPAddress.Loopback, currentUnityPort); + listener.Server.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + true + ); + // Minimize TIME_WAIT by sending RST on close + try + { + listener.Server.LingerState = new LingerOption(true, 0); + } + catch (Exception) + { + // Ignore if not supported on platform + } listener.Start(); - isRunning = true; - Debug.Log($"UnityMcpBridge started on fallback port {currentUnityPort}."); - Task.Run(ListenerLoop); - EditorApplication.update += ProcessCommands; + break; } - catch (Exception fallbackEx) + catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < maxImmediateRetries) { - Debug.LogError($"Failed to start on fallback port: {fallbackEx.Message}"); + attempt++; + Thread.Sleep(retrySleepMs); + continue; + } + catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries) + { + currentUnityPort = PortManager.GetPortWithFallback(); + listener = new TcpListener(IPAddress.Loopback, currentUnityPort); + listener.Server.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + true + ); + try + { + listener.Server.LingerState = new LingerOption(true, 0); + } + catch (Exception) + { + } + listener.Start(); + break; } } - else - { - Debug.LogError($"Failed to start TCP listener: {ex.Message}"); - } + + isRunning = true; + isAutoConnectMode = false; + Debug.Log($"UnityMcpBridge started on port {currentUnityPort}."); + Task.Run(ListenerLoop); + EditorApplication.update += ProcessCommands; + // Write initial heartbeat immediately + WriteHeartbeat(false); + nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; + } + catch (SocketException ex) + { + Debug.LogError($"Failed to start TCP listener: {ex.Message}"); } } } @@ -215,6 +227,8 @@ public static void Stop() try { + // Mark heartbeat one last time before stopping + WriteHeartbeat(false); listener?.Stop(); listener = null; isRunning = false; @@ -317,6 +331,14 @@ private static void ProcessCommands() List processedIds = new(); lock (lockObj) { + // Periodic heartbeat while editor is idle/processing + double now = EditorApplication.timeSinceStartup; + if (now >= nextHeartbeatAt) + { + WriteHeartbeat(false); + nextHeartbeatAt = now + 0.5f; + } + foreach ( KeyValuePair< string, @@ -544,5 +566,59 @@ private static string GetParamsSummary(JObject @params) return "Could not summarize parameters"; } } + + // Heartbeat/status helpers + private static void OnBeforeAssemblyReload() + { + WriteHeartbeat(true); + } + + private static void OnAfterAssemblyReload() + { + // Will be overwritten by Start(), but mark as alive quickly + WriteHeartbeat(false); + } + + private static void WriteHeartbeat(bool reloading) + { + try + { + string dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + Directory.CreateDirectory(dir); + string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); + var payload = new + { + unity_port = currentUnityPort, + reloading, + project_path = Application.dataPath, + last_heartbeat = DateTime.UtcNow.ToString("O") + }; + File.WriteAllText(filePath, JsonConvert.SerializeObject(payload)); + } + catch (Exception) + { + // Best-effort only + } + } + + private static string ComputeProjectHash(string input) + { + try + { + using var sha1 = System.Security.Cryptography.SHA1.Create(); + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new System.Text.StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString()[..8]; + } + catch + { + return "default"; + } + } } } diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 7dc485b3..20daedf9 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -104,19 +104,8 @@ private void UpdatePythonServerInstallationStatus() if (File.Exists(Path.Combine(serverPath, "server.py"))) { - string installedVersion = ServerInstaller.GetInstalledVersion(); - string latestVersion = ServerInstaller.GetLatestVersion(); - - if (ServerInstaller.IsNewerVersion(latestVersion, installedVersion)) - { - pythonServerInstallationStatus = "Newer Version Available"; - pythonServerInstallationStatusColor = Color.yellow; - } - else - { - pythonServerInstallationStatus = "Up to Date"; - pythonServerInstallationStatusColor = Color.green; - } + pythonServerInstallationStatus = "Installed (Embedded)"; + pythonServerInstallationStatusColor = Color.green; } else { diff --git a/UnityMcpBridge/UnityMcpServer/src.meta b/UnityMcpBridge/UnityMcpServer/src.meta new file mode 100644 index 00000000..8495be14 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 661ad50b20643440fbed55a237c6db95 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/.python-version b/UnityMcpBridge/UnityMcpServer/src/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/UnityMcpBridge/UnityMcpServer/src/Dockerfile b/UnityMcpBridge/UnityMcpServer/src/Dockerfile new file mode 100644 index 00000000..3f884f37 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim + +# Install required system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Install uv package manager +RUN pip install uv + +# Copy required files +COPY config.py /app/ +COPY server.py /app/ +COPY unity_connection.py /app/ +COPY pyproject.toml /app/ +COPY __init__.py /app/ +COPY tools/ /app/tools/ + +# Install dependencies using uv +RUN uv pip install --system -e . + + +# Command to run the server +CMD ["uv", "run", "server.py"] \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/Dockerfile.meta b/UnityMcpBridge/UnityMcpServer/src/Dockerfile.meta new file mode 100644 index 00000000..8b821f0e --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/Dockerfile.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6fa88615288954da09edbaa8118d833d +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/UnityMcpServer.egg-info.meta b/UnityMcpBridge/UnityMcpServer/src/UnityMcpServer.egg-info.meta new file mode 100644 index 00000000..f5377ed4 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/UnityMcpServer.egg-info.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e90a4cfea1025423da33a86d17d4fbd3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/__init__.py b/UnityMcpBridge/UnityMcpServer/src/__init__.py new file mode 100644 index 00000000..62e5cd1f --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/__init__.py @@ -0,0 +1,3 @@ +""" +Unity MCP Server package. +""" \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/__init__.py.meta b/UnityMcpBridge/UnityMcpServer/src/__init__.py.meta new file mode 100644 index 00000000..5cad7ab5 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/__init__.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 59ba898760fd24167997d22d2705b8a4 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/config.py b/UnityMcpBridge/UnityMcpServer/src/config.py new file mode 100644 index 00000000..485b845d --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/config.py @@ -0,0 +1,30 @@ +""" +Configuration settings for the Unity MCP Server. +This file contains all configurable parameters for the server. +""" + +from dataclasses import dataclass + +@dataclass +class ServerConfig: + """Main configuration class for the MCP server.""" + + # Network settings + unity_host: str = "localhost" + unity_port: int = 6400 + mcp_port: int = 6500 + + # Connection settings + connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts + buffer_size: int = 16 * 1024 * 1024 # 16MB buffer + + # Logging settings + log_level: str = "INFO" + log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + # Server settings + max_retries: int = 8 + retry_delay: float = 0.5 + +# Create a global config instance +config = ServerConfig() \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/config.py.meta b/UnityMcpBridge/UnityMcpServer/src/config.py.meta new file mode 100644 index 00000000..75f04e78 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/config.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5516f911d79504c71976757e67ca228b +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/port_discovery.py b/UnityMcpBridge/UnityMcpServer/src/port_discovery.py new file mode 100644 index 00000000..98855333 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/port_discovery.py @@ -0,0 +1,155 @@ +""" +Port discovery utility for Unity MCP Server. + +What changed and why: +- Unity now writes a per-project port file named like + `~/.unity-mcp/unity-mcp-port-.json` to avoid projects overwriting + each other's saved port. The legacy file `unity-mcp-port.json` may still + exist. +- This module now scans for both patterns, prefers the most recently + modified file, and verifies that the port is actually a Unity MCP listener + (quick socket connect + ping) before choosing it. +""" + +import json +import os +import logging +from pathlib import Path +from typing import Optional, List +import glob +import socket + +logger = logging.getLogger("unity-mcp-server") + +class PortDiscovery: + """Handles port discovery from Unity Bridge registry""" + REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file + DEFAULT_PORT = 6400 + CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery + + @staticmethod + def get_registry_path() -> Path: + """Get the path to the port registry file""" + return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE + + @staticmethod + def get_registry_dir() -> Path: + return Path.home() / ".unity-mcp" + + @staticmethod + def list_candidate_files() -> List[Path]: + """Return candidate registry files, newest first. + Includes hashed per-project files and the legacy file (if present). + """ + base = PortDiscovery.get_registry_dir() + hashed = sorted( + (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + legacy = PortDiscovery.get_registry_path() + if legacy.exists(): + # Put legacy at the end so hashed, per-project files win + hashed.append(legacy) + return hashed + + @staticmethod + def _try_probe_unity_mcp(port: int) -> bool: + """Quickly check if a Unity MCP listener is on this port. + Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. + """ + try: + with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: + s.settimeout(PortDiscovery.CONNECT_TIMEOUT) + try: + s.sendall(b"ping") + data = s.recv(512) + # Minimal validation: look for a success pong response + if data and b'"message":"pong"' in data: + return True + except Exception: + return False + except Exception: + return False + return False + + @staticmethod + def _read_latest_status() -> Optional[dict]: + try: + base = PortDiscovery.get_registry_dir() + status_files = sorted( + (Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not status_files: + return None + with status_files[0].open('r') as f: + return json.load(f) + except Exception: + return None + + @staticmethod + def discover_unity_port() -> int: + """ + Discover Unity port by scanning per-project and legacy registry files. + Prefer the newest file whose port responds; fall back to first parsed + value; finally default to 6400. + + Returns: + Port number to connect to + """ + # Prefer the latest heartbeat status if it points to a responsive port + status = PortDiscovery._read_latest_status() + if status: + port = status.get('unity_port') + if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): + logger.info(f"Using Unity port from status: {port}") + return port + + candidates = PortDiscovery.list_candidate_files() + + first_seen_port: Optional[int] = None + + for path in candidates: + try: + with open(path, 'r') as f: + cfg = json.load(f) + unity_port = cfg.get('unity_port') + if isinstance(unity_port, int): + if first_seen_port is None: + first_seen_port = unity_port + if PortDiscovery._try_probe_unity_mcp(unity_port): + logger.info(f"Using Unity port from {path.name}: {unity_port}") + return unity_port + except Exception as e: + logger.warning(f"Could not read port registry {path}: {e}") + + if first_seen_port is not None: + logger.info(f"No responsive port found; using first seen value {first_seen_port}") + return first_seen_port + + # Fallback to default port + logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") + return PortDiscovery.DEFAULT_PORT + + @staticmethod + def get_port_config() -> Optional[dict]: + """ + Get the most relevant port configuration from registry. + Returns the most recent hashed file's config if present, + otherwise the legacy file's config. Returns None if nothing exists. + + Returns: + Port configuration dict or None if not found + """ + candidates = PortDiscovery.list_candidate_files() + if not candidates: + return None + for path in candidates: + try: + with open(path, 'r') as f: + return json.load(f) + except Exception as e: + logger.warning(f"Could not read port configuration {path}: {e}") + return None \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/port_discovery.py.meta b/UnityMcpBridge/UnityMcpServer/src/port_discovery.py.meta new file mode 100644 index 00000000..e792556d --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/port_discovery.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8d315755217ea4c36b221ac0461032ab +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/pyproject.toml b/UnityMcpBridge/UnityMcpServer/src/pyproject.toml new file mode 100644 index 00000000..eebcde11 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "UnityMcpServer" +version = "2.0.0" +description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." +readme = "README.md" +requires-python = ">=3.12" +dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"] + +[build-system] +requires = ["setuptools>=64.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = ["config", "server", "unity_connection"] +packages = ["tools"] diff --git a/UnityMcpBridge/UnityMcpServer/src/pyproject.toml.meta b/UnityMcpBridge/UnityMcpServer/src/pyproject.toml.meta new file mode 100644 index 00000000..86408e10 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/pyproject.toml.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 66fbd8ab4fd094540ba73299b6a2424a +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/server.py b/UnityMcpBridge/UnityMcpServer/src/server.py new file mode 100644 index 00000000..55360b57 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/server.py @@ -0,0 +1,73 @@ +from mcp.server.fastmcp import FastMCP, Context, Image +import logging +from dataclasses import dataclass +from contextlib import asynccontextmanager +from typing import AsyncIterator, Dict, Any, List +from config import config +from tools import register_all_tools +from unity_connection import get_unity_connection, UnityConnection + +# Configure logging using settings from config +logging.basicConfig( + level=getattr(logging, config.log_level), + format=config.log_format +) +logger = logging.getLogger("unity-mcp-server") + +# Global connection state +_unity_connection: UnityConnection = None + +@asynccontextmanager +async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: + """Handle server startup and shutdown.""" + global _unity_connection + logger.info("Unity MCP Server starting up") + try: + _unity_connection = get_unity_connection() + logger.info("Connected to Unity on startup") + except Exception as e: + logger.warning(f"Could not connect to Unity on startup: {str(e)}") + _unity_connection = None + try: + # Yield the connection object so it can be attached to the context + # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) + yield {"bridge": _unity_connection} + finally: + if _unity_connection: + _unity_connection.disconnect() + _unity_connection = None + logger.info("Unity MCP Server shut down") + +# Initialize MCP server +mcp = FastMCP( + "unity-mcp-server", + description="Unity Editor integration via Model Context Protocol", + lifespan=server_lifespan +) + +# Register all tools +register_all_tools(mcp) + +# Asset Creation Strategy + +@mcp.prompt() +def asset_creation_strategy() -> str: + """Guide for discovering and using Unity MCP tools effectively.""" + return ( + "Available Unity MCP Server Tools:\\n\\n" + "- `manage_editor`: Controls editor state and queries info.\\n" + "- `execute_menu_item`: Executes Unity Editor menu items by path.\\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" + "- `manage_script`: Manages C# script files.\\n" + "- `manage_asset`: Manages prefabs and assets.\\n" + "- `manage_shader`: Manages shaders.\\n\\n" + "Tips:\\n" + "- Create prefabs for reusable GameObjects.\\n" + "- Always include a camera and main light in your scenes.\\n" + ) + +# Run the server +if __name__ == "__main__": + mcp.run(transport='stdio') diff --git a/UnityMcpBridge/UnityMcpServer/src/server.py.meta b/UnityMcpBridge/UnityMcpServer/src/server.py.meta new file mode 100644 index 00000000..4e1c95b8 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/server.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8ef892978afc74491b6cf65f40514e74 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools.meta b/UnityMcpBridge/UnityMcpServer/src/tools.meta new file mode 100644 index 00000000..0b8416a1 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 205ac300b2209414f8b246354e853777 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py new file mode 100644 index 00000000..4d8d63cf --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py @@ -0,0 +1,21 @@ +from .manage_script import register_manage_script_tools +from .manage_scene import register_manage_scene_tools +from .manage_editor import register_manage_editor_tools +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 + +def register_all_tools(mcp): + """Register all refactored tools with the MCP server.""" + print("Registering Unity MCP Server refactored tools...") + register_manage_script_tools(mcp) + register_manage_scene_tools(mcp) + register_manage_editor_tools(mcp) + register_manage_gameobject_tools(mcp) + register_manage_asset_tools(mcp) + register_manage_shader_tools(mcp) + register_read_console_tools(mcp) + register_execute_menu_item_tools(mcp) + print("Unity MCP Server tool registration complete.") diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py.meta new file mode 100644 index 00000000..56b02253 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 85da958dba57e47b9a2fa32a8abd61ef +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py b/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py new file mode 100644 index 00000000..a4ebc672 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py @@ -0,0 +1,51 @@ +""" +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 # Import unity_connection module + +def register_execute_menu_item_tools(mcp: FastMCP): + """Registers the execute_menu_item tool with the MCP server.""" + + @mcp.tool() + async def execute_menu_item( + ctx: Context, + 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 + + # Get Unity connection and send the command + # We use the unity_connection module to communicate with Unity + unity_conn = get_unity_connection() + + # Send command to the ExecuteMenuItem C# handler + # The command type should match what the Unity side expects + return unity_conn.send_command("execute_menu_item", params_dict) \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py.meta new file mode 100644 index 00000000..16b394f5 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 50ba0cffcdba2452a89ac372d67b4787 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py new file mode 100644 index 00000000..dada66b3 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py @@ -0,0 +1,83 @@ +""" +Defines the manage_asset tool for interacting with Unity assets. +""" +import asyncio # Added: Import asyncio for running sync code in async +from typing import Dict, Any +from mcp.server.fastmcp import FastMCP, Context +# from ..unity_connection import get_unity_connection # Original line that caused error +from unity_connection import get_unity_connection # Use absolute import relative to Python dir + +def register_manage_asset_tools(mcp: FastMCP): + """Registers the manage_asset tool with the MCP server.""" + + @mcp.tool() + async def manage_asset( + ctx: Context, + action: str, + path: str, + asset_type: str = None, + properties: Dict[str, Any] = None, + destination: str = None, + generate_preview: bool = False, + search_pattern: str = None, + filter_type: str = None, + filter_date_after: str = None, + page_size: int = None, + page_number: int = None + ) -> Dict[str, Any]: + """Performs asset operations (import, create, modify, delete, etc.) in Unity. + + Args: + ctx: The MCP context. + action: Operation to perform (e.g., 'import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components'). + path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope. + asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'. + properties: Dictionary of properties for 'create'/'modify'. + example properties for Material: {"color": [1, 0, 0, 1], "shader": "Standard"}. + example properties for Texture: {"width": 1024, "height": 1024, "format": "RGBA32"}. + example properties for PhysicsMaterial: {"bounciness": 1.0, "staticFriction": 0.5, "dynamicFriction": 0.5}. + destination: Target path for 'duplicate'/'move'. + search_pattern: Search pattern (e.g., '*.prefab'). + filter_*: Filters for search (type, date). + page_*: Pagination for search. + + Returns: + A dictionary with operation results ('success', 'data', 'error'). + """ + # Ensure properties is a dict if None + if properties is None: + properties = {} + + # Prepare parameters for the C# handler + params_dict = { + "action": action.lower(), + "path": path, + "assetType": asset_type, + "properties": properties, + "destination": destination, + "generatePreview": generate_preview, + "searchPattern": search_pattern, + "filterType": filter_type, + "filterDateAfter": filter_date_after, + "pageSize": page_size, + "pageNumber": page_number + } + + # Remove None values to avoid sending unnecessary nulls + 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() + # Get the Unity connection instance + connection = get_unity_connection() + + # Run the synchronous send_command in the default executor (thread pool) + # This prevents blocking the main async event loop. + result = await loop.run_in_executor( + None, # Use default executor + connection.send_command, # The function to call + "manage_asset", # First argument for send_command + params_dict # Second argument for send_command + ) + # Return the result obtained from Unity + return result \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py.meta new file mode 100644 index 00000000..e0372a46 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7c0cfde2907ef4306b8a46c4b190f96a +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py new file mode 100644 index 00000000..b256e6cf --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py @@ -0,0 +1,53 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any +from unity_connection import get_unity_connection + +def register_manage_editor_tools(mcp: FastMCP): + """Register all editor management tools with the MCP server.""" + + @mcp.tool() + def manage_editor( + ctx: Context, + action: str, + wait_for_completion: bool = None, + # --- Parameters for specific actions --- + tool_name: str = None, + tag_name: str = None, + layer_name: str = None, + ) -> Dict[str, Any]: + """Controls and queries the Unity editor's state and settings. + + Args: + action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag'). + wait_for_completion: Optional. If True, waits for certain actions. + Action-specific arguments (e.g., tool_name, tag_name, layer_name). + + Returns: + Dictionary with operation results ('success', 'message', 'data'). + """ + try: + # Prepare parameters, removing None values + params = { + "action": action, + "waitForCompletion": wait_for_completion, + "toolName": tool_name, # Corrected parameter name to match C# + "tagName": tag_name, # Pass tag name + "layerName": layer_name, # Pass layer name + # Add other parameters based on the action being performed + # "width": width, + # "height": height, + # etc. + } + params = {k: v for k, v in params.items() if v is not None} + + # Send command to Unity + response = get_unity_connection().send_command("manage_editor", params) + + # Process response + if response.get("success"): + return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred during editor management.")} + + except Exception as e: + return {"success": False, "message": f"Python error managing editor: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py.meta new file mode 100644 index 00000000..1f112d77 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 54f6646d00435410fb67cc17d095c977 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py new file mode 100644 index 00000000..83ab9c74 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py @@ -0,0 +1,138 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any, List +from unity_connection import get_unity_connection + +def register_manage_gameobject_tools(mcp: FastMCP): + """Register all GameObject management tools with the MCP server.""" + + @mcp.tool() + def manage_gameobject( + ctx: Context, + action: str, + target: str = None, # GameObject identifier by name or path + search_method: str = None, + # --- Combined Parameters for Create/Modify --- + name: str = None, # Used for both 'create' (new object name) and 'modify' (rename) + tag: str = None, # Used for both 'create' (initial tag) and 'modify' (change tag) + parent: str = None, # Used for both 'create' (initial parent) and 'modify' (change parent) + position: List[float] = None, + rotation: List[float] = None, + scale: List[float] = None, + components_to_add: List[str] = None, # List of component names to add + primitive_type: str = None, + save_as_prefab: bool = False, + prefab_path: str = None, + prefab_folder: str = "Assets/Prefabs", + # --- Parameters for 'modify' --- + set_active: bool = None, + layer: str = None, # Layer name + components_to_remove: List[str] = None, + component_properties: Dict[str, Dict[str, Any]] = None, + # --- Parameters for 'find' --- + search_term: str = None, + find_all: bool = False, + search_in_children: bool = False, + search_inactive: bool = False, + # -- Component Management Arguments -- + component_name: str = None, + includeNonPublicSerialized: bool = None, # Controls serialization of private [SerializeField] fields + ) -> Dict[str, Any]: + """Manages GameObjects: create, modify, delete, find, and component operations. + + Args: + action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property', 'get_components'). + target: GameObject identifier (name or path string) for modify/delete/component actions. + search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups. + name: GameObject name - used for both 'create' (initial name) and 'modify' (rename). + tag: Tag name - used for both 'create' (initial tag) and 'modify' (change tag). + parent: Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent). + layer: Layer name - used for both 'create' (initial layer) and 'modify' (change layer). + component_properties: Dict mapping Component names to their properties to set. + Example: {"Rigidbody": {"mass": 10.0, "useGravity": True}}, + To set references: + - Use asset path string for Prefabs/Materials, e.g., {"MeshRenderer": {"material": "Assets/Materials/MyMat.mat"}} + - Use a dict for scene objects/components, e.g.: + {"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}} (assigns GameObject) + {"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}} (assigns Component) + Example set nested property: + - Access shared material: {"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}} + components_to_add: List of component names to add. + Action-specific arguments (e.g., position, rotation, scale for create/modify; + component_name for component actions; + search_term, find_all for 'find'). + includeNonPublicSerialized: If True, includes private fields marked [SerializeField] in component data. + + Action-specific details: + - For 'get_components': + Required: target, search_method + Optional: includeNonPublicSerialized (defaults to True) + Returns all components on the target GameObject with their serialized data. + The search_method parameter determines how to find the target ('by_name', 'by_id', 'by_path'). + + Returns: + Dictionary with operation results ('success', 'message', 'data'). + For 'get_components', the 'data' field contains a dictionary of component names and their serialized properties. + """ + try: + # --- Early check for attempting to modify a prefab asset --- + # ---------------------------------------------------------- + + # Prepare parameters, removing None values + params = { + "action": action, + "target": target, + "searchMethod": search_method, + "name": name, + "tag": tag, + "parent": parent, + "position": position, + "rotation": rotation, + "scale": scale, + "componentsToAdd": components_to_add, + "primitiveType": primitive_type, + "saveAsPrefab": save_as_prefab, + "prefabPath": prefab_path, + "prefabFolder": prefab_folder, + "setActive": set_active, + "layer": layer, + "componentsToRemove": components_to_remove, + "componentProperties": component_properties, + "searchTerm": search_term, + "findAll": find_all, + "searchInChildren": search_in_children, + "searchInactive": search_inactive, + "componentName": component_name, + "includeNonPublicSerialized": includeNonPublicSerialized + } + params = {k: v for k, v in params.items() if v is not None} + + # --- Handle Prefab Path Logic --- + if action == "create" and params.get("saveAsPrefab"): # Check if 'saveAsPrefab' is explicitly True in params + if "prefabPath" not in params: + if "name" not in params or not params["name"]: + return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} + # Use the provided prefab_folder (which has a default) and the name to construct the path + constructed_path = f"{prefab_folder}/{params['name']}.prefab" + # Ensure clean path separators (Unity prefers '/') + params["prefabPath"] = constructed_path.replace("\\", "/") + elif not params["prefabPath"].lower().endswith(".prefab"): + return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} + # Ensure prefab_folder itself isn't sent if prefabPath was constructed or provided + # The C# side only needs the final prefabPath + params.pop("prefab_folder", None) + # -------------------------------- + + # Send the command to Unity via the established connection + # Use the get_unity_connection function to retrieve the active connection instance + # Changed "MANAGE_GAMEOBJECT" to "manage_gameobject" to potentially match Unity expectation + response = get_unity_connection().send_command("manage_gameobject", params) + + # Check if the response indicates success + # If the response is not successful, raise an exception with the error message + if response.get("success"): + return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred during GameObject management.")} + + except Exception as e: + return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py.meta new file mode 100644 index 00000000..9fc044f7 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 393f17281b99c428dbe73ba8652b60f5 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py new file mode 100644 index 00000000..44981f65 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py @@ -0,0 +1,47 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any +from unity_connection import get_unity_connection + +def register_manage_scene_tools(mcp: FastMCP): + """Register all scene management tools with the MCP server.""" + + @mcp.tool() + def manage_scene( + ctx: Context, + action: str, + name: str, + path: str, + build_index: int, + ) -> Dict[str, Any]: + """Manages Unity scenes (load, save, create, get hierarchy, etc.). + + Args: + action: Operation (e.g., 'load', 'save', 'create', 'get_hierarchy'). + name: Scene name (no extension) for create/load/save. + path: Asset path for scene operations (default: "Assets/"). + build_index: Build index for load/build settings actions. + # Add other action-specific args as needed (e.g., for hierarchy depth) + + Returns: + Dictionary with results ('success', 'message', 'data'). + """ + try: + params = { + "action": action, + "name": name, + "path": path, + "buildIndex": build_index + } + params = {k: v for k, v in params.items() if v is not None} + + # Send command to Unity + response = get_unity_connection().send_command("manage_scene", params) + + # Process response + if response.get("success"): + return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred during scene management.")} + + except Exception as e: + return {"success": False, "message": f"Python error managing scene: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py.meta new file mode 100644 index 00000000..a4feb8f0 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a744081d28b1e4ace9bfe8d6c4309640 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py new file mode 100644 index 00000000..22e09530 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py @@ -0,0 +1,74 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any +from unity_connection import get_unity_connection +import os +import base64 + +def register_manage_script_tools(mcp: FastMCP): + """Register all script management tools with the MCP server.""" + + @mcp.tool() + def manage_script( + ctx: Context, + action: str, + name: str, + path: str, + contents: str, + script_type: str, + namespace: str + ) -> Dict[str, Any]: + """Manages C# scripts in Unity (create, read, update, delete). + Make reference variables public for easier access in the Unity Editor. + + Args: + action: Operation ('create', 'read', 'update', 'delete'). + name: Script name (no .cs extension). + path: Asset path (default: "Assets/"). + contents: C# code for 'create'/'update'. + script_type: Type hint (e.g., 'MonoBehaviour'). + namespace: Script namespace. + + Returns: + Dictionary with results ('success', 'message', 'data'). + """ + try: + # Prepare parameters for Unity + params = { + "action": action, + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type + } + + # Base64 encode the contents if they exist to avoid JSON escaping issues + if contents is not None: + if action in ['create', 'update']: + # Encode content for safer transmission + params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') + params["contentsEncoded"] = True + else: + params["contents"] = contents + + # Remove None values so they don't get sent as null + params = {k: v for k, v in params.items() if v is not None} + + # Send command to Unity + response = get_unity_connection().send_command("manage_script", params) + + # Process response from Unity + if response.get("success"): + # If the response contains base64 encoded content, decode it + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + + return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred.")} + + except Exception as e: + # Handle Python-side errors (e.g., connection issues) + return {"success": False, "message": f"Python error managing script: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py.meta new file mode 100644 index 00000000..8ec9f2ee --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5f5d55725198d4d53afcd4565f402b9e +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py new file mode 100644 index 00000000..c447a3a3 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py @@ -0,0 +1,67 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any +from unity_connection import get_unity_connection +import os +import base64 + +def register_manage_shader_tools(mcp: FastMCP): + """Register all shader script management tools with the MCP server.""" + + @mcp.tool() + def manage_shader( + ctx: Context, + action: str, + name: str, + path: str, + contents: str, + ) -> Dict[str, Any]: + """Manages shader scripts in Unity (create, read, update, delete). + + Args: + action: Operation ('create', 'read', 'update', 'delete'). + name: Shader name (no .cs extension). + path: Asset path (default: "Assets/"). + contents: Shader code for 'create'/'update'. + + Returns: + Dictionary with results ('success', 'message', 'data'). + """ + try: + # Prepare parameters for Unity + params = { + "action": action, + "name": name, + "path": path, + } + + # Base64 encode the contents if they exist to avoid JSON escaping issues + if contents is not None: + if action in ['create', 'update']: + # Encode content for safer transmission + params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') + params["contentsEncoded"] = True + else: + params["contents"] = contents + + # Remove None values so they don't get sent as null + params = {k: v for k, v in params.items() if v is not None} + + # Send command to Unity + response = get_unity_connection().send_command("manage_shader", params) + + # Process response from Unity + if response.get("success"): + # If the response contains base64 encoded content, decode it + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + + return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred.")} + + except Exception as e: + # Handle Python-side errors (e.g., connection issues) + return {"success": False, "message": f"Python error managing shader: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py.meta new file mode 100644 index 00000000..bdadaaa0 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 52a3e6faa53234aa08edf8163159c9af +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py new file mode 100644 index 00000000..3d4bd121 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py @@ -0,0 +1,70 @@ +""" +Defines the read_console tool for accessing Unity Editor console messages. +""" +from typing import List, Dict, Any +from mcp.server.fastmcp import FastMCP, Context +from unity_connection import get_unity_connection + +def register_read_console_tools(mcp: FastMCP): + """Registers the read_console tool with the MCP server.""" + + @mcp.tool() + def read_console( + ctx: Context, + action: str = None, + types: List[str] = None, + count: int = None, + filter_text: str = None, + since_timestamp: str = None, + format: str = None, + include_stacktrace: bool = None + ) -> Dict[str, Any]: + """Gets messages from or clears the Unity Editor console. + + Args: + ctx: The MCP context. + action: Operation ('get' or 'clear'). + types: Message types to get ('error', 'warning', 'log', 'all'). + count: Max messages to return. + filter_text: Text filter for messages. + since_timestamp: Get messages after this timestamp (ISO 8601). + format: Output format ('plain', 'detailed', 'json'). + include_stacktrace: Include stack traces in output. + + Returns: + Dictionary with results. For 'get', includes 'data' (messages). + """ + + # Get the connection instance + bridge = get_unity_connection() + + # Set defaults if values are None + action = action if action is not None else 'get' + types = types if types is not None else ['error', 'warning', 'log'] + format = format if format is not None else 'detailed' + include_stacktrace = include_stacktrace if include_stacktrace is not None else True + + # Normalize action if it's a string + if isinstance(action, str): + action = action.lower() + + # Prepare parameters for the C# handler + params_dict = { + "action": action, + "types": types, + "count": count, + "filterText": filter_text, + "sinceTimestamp": since_timestamp, + "format": format.lower() if isinstance(format, str) else format, + "includeStacktrace": include_stacktrace + } + + # Remove None values unless it's 'count' (as None might mean 'all') + params_dict = {k: v for k, v in params_dict.items() if v is not None or k == 'count'} + + # Add count back if it was None, explicitly sending null might be important for C# logic + if 'count' not in params_dict: + params_dict['count'] = None + + # Forward the command using the bridge's send_command method + return bridge.send_command("read_console", params_dict) \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py.meta new file mode 100644 index 00000000..3ef3e8a2 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a73ff5df6153548878e2656315e5db69 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer/src/unity_connection.py new file mode 100644 index 00000000..dbf7703e --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/unity_connection.py @@ -0,0 +1,239 @@ +import socket +import json +import logging +from dataclasses import dataclass +from pathlib import Path +import time +from typing import Dict, Any +from config import config +from port_discovery import PortDiscovery + +# Configure logging using settings from config +logging.basicConfig( + level=getattr(logging, config.log_level), + format=config.log_format +) +logger = logging.getLogger("unity-mcp-server") + +@dataclass +class UnityConnection: + """Manages the socket connection to the Unity Editor.""" + host: str = config.unity_host + port: int = None # Will be set dynamically + sock: socket.socket = None # Socket for Unity communication + + def __post_init__(self): + """Set port from discovery if not explicitly provided""" + if self.port is None: + self.port = PortDiscovery.discover_unity_port() + + def connect(self) -> bool: + """Establish a connection to the Unity Editor.""" + if self.sock: + return True + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((self.host, self.port)) + logger.info(f"Connected to Unity at {self.host}:{self.port}") + return True + except Exception as e: + logger.error(f"Failed to connect to Unity: {str(e)}") + self.sock = None + return False + + def disconnect(self): + """Close the connection to the Unity Editor.""" + if self.sock: + try: + self.sock.close() + except Exception as e: + logger.error(f"Error disconnecting from Unity: {str(e)}") + finally: + self.sock = None + + def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: + """Receive a complete response from Unity, handling chunked data.""" + chunks = [] + sock.settimeout(config.connection_timeout) # Use timeout from config + try: + while True: + chunk = sock.recv(buffer_size) + if not chunk: + if not chunks: + raise Exception("Connection closed before receiving data") + break + chunks.append(chunk) + + # Process the data received so far + data = b''.join(chunks) + decoded_data = data.decode('utf-8') + + # Check if we've received a complete response + try: + # Special case for ping-pong + if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): + logger.debug("Received ping response") + return data + + # Handle escaped quotes in the content + if '"content":' in decoded_data: + # Find the content field and its value + content_start = decoded_data.find('"content":') + 9 + content_end = decoded_data.rfind('"', content_start) + if content_end > content_start: + # Replace escaped quotes in content with regular quotes + content = decoded_data[content_start:content_end] + content = content.replace('\\"', '"') + decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:] + + # Validate JSON format + json.loads(decoded_data) + + # If we get here, we have valid JSON + logger.info(f"Received complete response ({len(data)} bytes)") + return data + except json.JSONDecodeError: + # We haven't received a complete valid JSON response yet + continue + except Exception as e: + logger.warning(f"Error processing response chunk: {str(e)}") + # Continue reading more chunks as this might not be the complete response + continue + except socket.timeout: + logger.warning("Socket timeout during receive") + raise Exception("Timeout receiving Unity response") + except Exception as e: + logger.error(f"Error during receive: {str(e)}") + raise + + def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: + """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" + attempts = max(config.max_retries, 5) + base_backoff = max(0.5, config.retry_delay) + + def read_status_file() -> dict | None: + try: + status_files = sorted(Path.home().joinpath('.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) + if not status_files: + return None + latest = status_files[0] + with latest.open('r') as f: + return json.load(f) + except Exception: + return None + + last_short_timeout = None + + for attempt in range(attempts + 1): + try: + # Ensure connected + if not self.sock: + # During retries use short connect timeout + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(1.0) + self.sock.connect((self.host, self.port)) + # restore steady-state timeout for receive + self.sock.settimeout(config.connection_timeout) + logger.info(f"Connected to Unity at {self.host}:{self.port}") + + # Build payload + if command_type == 'ping': + payload = b'ping' + else: + command = {"type": command_type, "params": params or {}} + payload = json.dumps(command, ensure_ascii=False).encode('utf-8') + + # Send + self.sock.sendall(payload) + + # During retry bursts use a short receive timeout + if attempt > 0 and last_short_timeout is None: + last_short_timeout = self.sock.gettimeout() + self.sock.settimeout(1.0) + response_data = self.receive_full_response(self.sock) + # restore steady-state timeout if changed + if last_short_timeout is not None: + self.sock.settimeout(config.connection_timeout) + last_short_timeout = None + + # Parse + if command_type == 'ping': + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong': + return {"message": "pong"} + raise Exception("Ping unsuccessful") + + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'error': + err = resp.get('error') or resp.get('message', 'Unknown Unity error') + raise Exception(err) + return resp.get('result', {}) + except Exception as e: + logger.warning(f"Unity communication attempt {attempt+1} failed: {e}") + try: + if self.sock: + self.sock.close() + finally: + self.sock = None + + # Re-discover port each time + try: + new_port = PortDiscovery.discover_unity_port() + if new_port != self.port: + logger.info(f"Unity port changed {self.port} -> {new_port}") + self.port = new_port + except Exception as de: + logger.debug(f"Port discovery failed: {de}") + + if attempt < attempts: + # If heartbeat indicates reload, keep retries snappy without spamming + status = read_status_file() + backoff = base_backoff * (2 ** attempt) + sleep_s = min(backoff, 3.0) + if status and (status.get('reloading') or status.get('unity_port') == self.port): + sleep_s = min(sleep_s, 0.8) + time.sleep(sleep_s) + continue + raise + +# Global Unity connection +_unity_connection = None + +def get_unity_connection() -> UnityConnection: + """Retrieve or establish a persistent Unity connection.""" + global _unity_connection + if _unity_connection is not None: + try: + # Try to ping with a short timeout to verify connection + result = _unity_connection.send_command("ping") + # If we get here, the connection is still valid + logger.debug("Reusing existing Unity connection") + return _unity_connection + except Exception as e: + logger.warning(f"Existing connection failed: {str(e)}") + try: + _unity_connection.disconnect() + except: + pass + _unity_connection = None + + # Create a new connection + logger.info("Creating new Unity connection") + _unity_connection = UnityConnection() + if not _unity_connection.connect(): + _unity_connection = None + raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") + + try: + # Verify the new connection works + _unity_connection.send_command("ping") + logger.info("Successfully established new Unity connection") + return _unity_connection + except Exception as e: + logger.error(f"Could not verify new connection: {str(e)}") + try: + _unity_connection.disconnect() + except: + pass + _unity_connection = None + raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") diff --git a/UnityMcpBridge/UnityMcpServer/src/unity_connection.py.meta b/UnityMcpBridge/UnityMcpServer/src/unity_connection.py.meta new file mode 100644 index 00000000..e26b0321 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/unity_connection.py.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2bba70ed632654291acae6c529d6ec79 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/uv.lock b/UnityMcpBridge/UnityMcpServer/src/uv.lock new file mode 100644 index 00000000..bc3e54ca --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/uv.lock @@ -0,0 +1,349 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mcp" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/cc/5c5bb19f1a0f8f89a95e25cb608b0b07009e81fd4b031e519335404e1422/mcp-1.4.1.tar.gz", hash = "sha256:b9655d2de6313f9d55a7d1df62b3c3fe27a530100cc85bf23729145b0dba4c7a", size = 154942 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/0e/885f156ade60108e67bf044fada5269da68e29d758a10b0c513f4d85dd76/mcp-1.4.1-py3-none-any.whl", hash = "sha256:a7716b1ec1c054e76f49806f7d96113b99fc1166fc9244c2c6f19867cb75b593", size = 72448 }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sse-starlette" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, +] + +[[package]] +name = "starlette" +version = "0.46.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, +] + +[[package]] +name = "typer" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "unitymcpserver" +version = "2.0.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "mcp", extra = ["cli"] }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.2" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.4.1" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, +] diff --git a/UnityMcpBridge/UnityMcpServer/src/uv.lock.meta b/UnityMcpBridge/UnityMcpServer/src/uv.lock.meta new file mode 100644 index 00000000..4fa68530 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer/src/uv.lock.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9c116a2a729ac40348fb4c81c93ea030 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpServer/src/config.py b/UnityMcpServer/src/config.py index c42437a7..485b845d 100644 --- a/UnityMcpServer/src/config.py +++ b/UnityMcpServer/src/config.py @@ -15,7 +15,7 @@ class ServerConfig: mcp_port: int = 6500 # Connection settings - connection_timeout: float = 600.0 # 10 minutes timeout + connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts buffer_size: int = 16 * 1024 * 1024 # 16MB buffer # Logging settings @@ -23,8 +23,8 @@ class ServerConfig: log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" # Server settings - max_retries: int = 3 - retry_delay: float = 1.0 + max_retries: int = 8 + retry_delay: float = 0.5 # Create a global config instance config = ServerConfig() \ No newline at end of file diff --git a/UnityMcpServer/src/port_discovery.py b/UnityMcpServer/src/port_discovery.py index c09efe32..98855333 100644 --- a/UnityMcpServer/src/port_discovery.py +++ b/UnityMcpServer/src/port_discovery.py @@ -68,12 +68,26 @@ def _try_probe_unity_mcp(port: int) -> bool: if data and b'"message":"pong"' in data: return True except Exception: - # Even if the ping fails, a successful TCP connect is a strong signal. - # Fall back to treating the port as viable if connect succeeded. - return True + return False except Exception: return False return False + + @staticmethod + def _read_latest_status() -> Optional[dict]: + try: + base = PortDiscovery.get_registry_dir() + status_files = sorted( + (Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not status_files: + return None + with status_files[0].open('r') as f: + return json.load(f) + except Exception: + return None @staticmethod def discover_unity_port() -> int: @@ -85,6 +99,14 @@ def discover_unity_port() -> int: Returns: Port number to connect to """ + # Prefer the latest heartbeat status if it points to a responsive port + status = PortDiscovery._read_latest_status() + if status: + port = status.get('unity_port') + if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): + logger.info(f"Using Unity port from status: {port}") + return port + candidates = PortDiscovery.list_candidate_files() first_seen_port: Optional[int] = None diff --git a/UnityMcpServer/src/unity_connection.py b/UnityMcpServer/src/unity_connection.py index da88d9bd..dbf7703e 100644 --- a/UnityMcpServer/src/unity_connection.py +++ b/UnityMcpServer/src/unity_connection.py @@ -2,6 +2,8 @@ import json import logging from dataclasses import dataclass +from pathlib import Path +import time from typing import Dict, Any from config import config from port_discovery import PortDiscovery @@ -105,64 +107,94 @@ def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: raise def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: - """Send a command to Unity and return its response.""" - if not self.sock and not self.connect(): - raise ConnectionError("Not connected to Unity") - - # Special handling for ping command - if command_type == "ping": + """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" + attempts = max(config.max_retries, 5) + base_backoff = max(0.5, config.retry_delay) + + def read_status_file() -> dict | None: + try: + status_files = sorted(Path.home().joinpath('.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) + if not status_files: + return None + latest = status_files[0] + with latest.open('r') as f: + return json.load(f) + except Exception: + return None + + last_short_timeout = None + + for attempt in range(attempts + 1): try: - logger.debug("Sending ping to verify connection") - self.sock.sendall(b"ping") + # Ensure connected + if not self.sock: + # During retries use short connect timeout + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(1.0) + self.sock.connect((self.host, self.port)) + # restore steady-state timeout for receive + self.sock.settimeout(config.connection_timeout) + logger.info(f"Connected to Unity at {self.host}:{self.port}") + + # Build payload + if command_type == 'ping': + payload = b'ping' + else: + command = {"type": command_type, "params": params or {}} + payload = json.dumps(command, ensure_ascii=False).encode('utf-8') + + # Send + self.sock.sendall(payload) + + # During retry bursts use a short receive timeout + if attempt > 0 and last_short_timeout is None: + last_short_timeout = self.sock.gettimeout() + self.sock.settimeout(1.0) response_data = self.receive_full_response(self.sock) - response = json.loads(response_data.decode('utf-8')) - - if response.get("status") != "success": - logger.warning("Ping response was not successful") - self.sock = None - raise ConnectionError("Connection verification failed") - - return {"message": "pong"} + # restore steady-state timeout if changed + if last_short_timeout is not None: + self.sock.settimeout(config.connection_timeout) + last_short_timeout = None + + # Parse + if command_type == 'ping': + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong': + return {"message": "pong"} + raise Exception("Ping unsuccessful") + + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'error': + err = resp.get('error') or resp.get('message', 'Unknown Unity error') + raise Exception(err) + return resp.get('result', {}) except Exception as e: - logger.error(f"Ping error: {str(e)}") - self.sock = None - raise ConnectionError(f"Connection verification failed: {str(e)}") - - # Normal command handling - command = {"type": command_type, "params": params or {}} - try: - # Check for very large content that might cause JSON issues - command_size = len(json.dumps(command)) - - if command_size > config.buffer_size / 2: - logger.warning(f"Large command detected ({command_size} bytes). This might cause issues.") - - logger.info(f"Sending command: {command_type} with params size: {command_size} bytes") - - # Ensure we have a valid JSON string before sending - command_json = json.dumps(command, ensure_ascii=False) - self.sock.sendall(command_json.encode('utf-8')) - - response_data = self.receive_full_response(self.sock) - try: - response = json.loads(response_data.decode('utf-8')) - except json.JSONDecodeError as je: - logger.error(f"JSON decode error: {str(je)}") - # Log partial response for debugging - partial_response = response_data.decode('utf-8')[:500] + "..." if len(response_data) > 500 else response_data.decode('utf-8') - logger.error(f"Partial response: {partial_response}") - raise Exception(f"Invalid JSON response from Unity: {str(je)}") - - if response.get("status") == "error": - error_message = response.get("error") or response.get("message", "Unknown Unity error") - logger.error(f"Unity error: {error_message}") - raise Exception(error_message) - - return response.get("result", {}) - except Exception as e: - logger.error(f"Communication error with Unity: {str(e)}") - self.sock = None - raise Exception(f"Failed to communicate with Unity: {str(e)}") + logger.warning(f"Unity communication attempt {attempt+1} failed: {e}") + try: + if self.sock: + self.sock.close() + finally: + self.sock = None + + # Re-discover port each time + try: + new_port = PortDiscovery.discover_unity_port() + if new_port != self.port: + logger.info(f"Unity port changed {self.port} -> {new_port}") + self.port = new_port + except Exception as de: + logger.debug(f"Port discovery failed: {de}") + + if attempt < attempts: + # If heartbeat indicates reload, keep retries snappy without spamming + status = read_status_file() + backoff = base_backoff * (2 ** attempt) + sleep_s = min(backoff, 3.0) + if status and (status.get('reloading') or status.get('unity_port') == self.port): + sleep_s = min(sleep_s, 0.8) + time.sleep(sleep_s) + continue + raise # Global Unity connection _unity_connection = None From 5c632f0ab30be424c2e5e5604d82660d61b1ad53 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 8 Aug 2025 08:12:57 -0700 Subject: [PATCH 11/69] fix(package): add UnityMcpServer folder meta; remove stray egg-info meta --- .../src/UnityMcpServer.egg-info.meta => UnityMcpServer.meta} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename UnityMcpBridge/{UnityMcpServer/src/UnityMcpServer.egg-info.meta => UnityMcpServer.meta} (77%) diff --git a/UnityMcpBridge/UnityMcpServer/src/UnityMcpServer.egg-info.meta b/UnityMcpBridge/UnityMcpServer.meta similarity index 77% rename from UnityMcpBridge/UnityMcpServer/src/UnityMcpServer.egg-info.meta rename to UnityMcpBridge/UnityMcpServer.meta index f5377ed4..a391da27 100644 --- a/UnityMcpBridge/UnityMcpServer/src/UnityMcpServer.egg-info.meta +++ b/UnityMcpBridge/UnityMcpServer.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: e90a4cfea1025423da33a86d17d4fbd3 +guid: 1f4e9c3e4a2b4e12a1b2c3d4e5f60789 folderAsset: yes DefaultImporter: externalObjects: {} From 57592017ae07c3b51baf68a87c089d903322ff32 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 8 Aug 2025 08:18:33 -0700 Subject: [PATCH 12/69] fix(bridge): prefer persisted project port at start to avoid initial 6400 blip after UPM import --- UnityMcpBridge/Editor/UnityMcpBridge.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index a55a7ec4..a01468fb 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -141,12 +141,11 @@ public static void Start() Stop(); - // Attempt fast bind with same-port preference + // Attempt fast bind with stored-port preference (sticky per-project) try { - currentUnityPort = currentUnityPort > 0 && PortManager.IsPortAvailable(currentUnityPort) - ? currentUnityPort - : PortManager.GetPortWithFallback(); + // Always consult PortManager first so we prefer the persisted project port + currentUnityPort = PortManager.GetPortWithFallback(); const int maxImmediateRetries = 3; const int retrySleepMs = 75; From 85202d4ccb625a0b8f404c74fc7354ceb24703ab Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 8 Aug 2025 08:24:50 -0700 Subject: [PATCH 13/69] fix(editor): only treat dev mode when manifest uses file: path for package; remove dev-mode logs under UPM --- .../Editor/Windows/UnityMcpEditorWindow.cs | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 20daedf9..b74246d6 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -945,32 +945,23 @@ private bool IsDevelopmentMode() { try { - // Check if we're using a file-based package by looking at the manifest + // Only treat as development if manifest explicitly references a local file path for the package string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); - if (File.Exists(manifestPath)) - { - string manifestContent = File.ReadAllText(manifestPath); - // Look for file-based package reference - if (manifestContent.Contains("file:/") && manifestContent.Contains("unity-mcp")) - { - return true; - } - } - - // Also check if we're in a development environment by looking for common dev paths - string[] devIndicators = { - Path.Combine(Application.dataPath, "..", "unity-mcp"), - Path.Combine(Application.dataPath, "..", "..", "unity-mcp"), - }; - - foreach (string indicator in devIndicators) + if (!File.Exists(manifestPath)) return false; + + string manifestContent = File.ReadAllText(manifestPath); + // Look specifically for our package dependency set to a file: URL + // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk + if (manifestContent.IndexOf("\"com.justinpbarnett.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) { - if (Directory.Exists(indicator) && File.Exists(Path.Combine(indicator, "UnityMcpServer", "src", "server.py"))) + int idx = manifestContent.IndexOf("com.justinpbarnett.unity-mcp", StringComparison.OrdinalIgnoreCase); + // Crude but effective: check for "file:" in the same line/value + if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 + && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) { return true; } } - return false; } catch From 1b892dcf49d64f5ae6cc4f270c4e504d05c506b9 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 8 Aug 2025 08:32:20 -0700 Subject: [PATCH 14/69] fix(ports): write both hashed and legacy port files; compare project paths case-insensitively to prevent sticky-port drift across reloads --- UnityMcpBridge/Editor/Helpers/PortManager.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Helpers/PortManager.cs b/UnityMcpBridge/Editor/Helpers/PortManager.cs index 900cbd9a..9e12e7aa 100644 --- a/UnityMcpBridge/Editor/Helpers/PortManager.cs +++ b/UnityMcpBridge/Editor/Helpers/PortManager.cs @@ -38,7 +38,7 @@ public static int GetPortWithFallback() var storedConfig = GetStoredPortConfig(); if (storedConfig != null && storedConfig.unity_port > 0 && - storedConfig.project_path == Application.dataPath && + string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && IsPortAvailable(storedConfig.unity_port)) { Debug.Log($"Using stored port {storedConfig.unity_port} for current project"); @@ -196,7 +196,11 @@ private static void SavePort(int port) string registryFile = GetRegistryFilePath(); string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); + // Write to hashed, project-scoped file File.WriteAllText(registryFile, json); + // Also write to legacy stable filename to avoid hash/case drift across reloads + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + File.WriteAllText(legacy, json); Debug.Log($"Saved port {port} to storage"); } From f4bc7cd4fded943881c9e7d16271a802eb3ae400 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 8 Aug 2025 08:37:02 -0700 Subject: [PATCH 15/69] fix(ports): never hop to default when stored port busy; prefer stored port and let bind micro-retry handle release to avoid port swapping on recompiles --- UnityMcpBridge/Editor/Helpers/PortManager.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/UnityMcpBridge/Editor/Helpers/PortManager.cs b/UnityMcpBridge/Editor/Helpers/PortManager.cs index 9e12e7aa..9caeccca 100644 --- a/UnityMcpBridge/Editor/Helpers/PortManager.cs +++ b/UnityMcpBridge/Editor/Helpers/PortManager.cs @@ -53,6 +53,8 @@ public static int GetPortWithFallback() Debug.Log($"Stored port {storedConfig.unity_port} became available after short wait"); return storedConfig.unity_port; } + // Prefer sticking to the same port; let the caller handle bind retries/fallbacks + return storedConfig.unity_port; } // If no valid stored port, find a new one and save it From 10903a2d486cae0d996936c7151c65c3d822ae71 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 8 Aug 2025 10:35:00 -0700 Subject: [PATCH 16/69] fix(setup): reuse stored project port in StartAutoConnect to avoid port changes during client setup --- UnityMcpBridge/Editor/UnityMcpBridge.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index a01468fb..626849bb 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -45,8 +45,8 @@ public static void StartAutoConnect() try { - // Discover new port and save it - currentUnityPort = PortManager.DiscoverNewPort(); + // Reuse stored project port when available to avoid port changes during setup + currentUnityPort = PortManager.GetPortWithFallback(); listener = new TcpListener(IPAddress.Loopback, currentUnityPort); listener.Start(); From 2f387d3417a7b85c901fa0f19e2aee64d35e35c3 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 8 Aug 2025 10:49:01 -0700 Subject: [PATCH 17/69] chore(ui): rename 'Re-Run Client Setup' to 'Bind to Clients' --- UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index b74246d6..34103c66 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -214,8 +214,8 @@ private void DrawServerStatusSection() GUILayout.FlexibleSpace(); - // Run Client Setup button - if (GUILayout.Button("Re-Run Client Setup", GUILayout.Width(140), GUILayout.Height(24))) + // Bind to Clients button + if (GUILayout.Button("Bind to Clients", GUILayout.Width(140), GUILayout.Height(24))) { RunSetupNow(); } From 06f271926ba36782bd51e68741dfa365fb36bd31 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 8 Aug 2025 11:23:45 -0700 Subject: [PATCH 18/69] =?UTF-8?q?feat(editor):=202x2=20layout=20(Server/Br?= =?UTF-8?q?idge=20|=20Clients/Validation),=20Auto-Setup=20with=20Connected?= =?UTF-8?q?=20=E2=9C=93=20state;=20add=20Debug=20Logs=20toggle=20and=20gat?= =?UTF-8?q?e=20verbose=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(bridge): reuse stored port in StartAutoConnect; guard listener stop to avoid ObjectDisposedException chore(clients): reorder dropdown to Cursor, Claude Code, Windsurf, Claude Desktop, VSCode --- UnityMcpBridge/Editor/Data/McpClients.cs | 69 ++++++----- UnityMcpBridge/Editor/UnityMcpBridge.cs | 22 ++-- .../Editor/Windows/UnityMcpEditorWindow.cs | 117 ++++++++++++++---- 3 files changed, 144 insertions(+), 64 deletions(-) diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index aa97d337..362ecdcc 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -9,24 +9,24 @@ public class McpClients { public List clients = new() { + // 1) Cursor new() { - name = "Claude Desktop", + name = "Cursor", windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Claude", - "claude_desktop_config.json" + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cursor", + "mcp.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", - "Claude", - "claude_desktop_config.json" + ".cursor", + "mcp.json" ), - mcpType = McpTypes.ClaudeDesktop, + mcpType = McpTypes.Cursor, configStatus = "Not Configured", }, + // 2) Claude Code new() { name = "Claude Code", @@ -41,58 +41,63 @@ public class McpClients mcpType = McpTypes.ClaudeCode, configStatus = "Not Configured", }, + // 3) Windsurf new() { - name = "Cursor", + name = "Windsurf", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cursor", - "mcp.json" + ".codeium", + "windsurf", + "mcp_config.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cursor", - "mcp.json" + ".codeium", + "windsurf", + "mcp_config.json" ), - mcpType = McpTypes.Cursor, + mcpType = McpTypes.Windsurf, configStatus = "Not Configured", }, + // 4) Claude Desktop new() { - name = "VSCode GitHub Copilot", + name = "Claude Desktop", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Code", - "User", - "settings.json" + "Claude", + "claude_desktop_config.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", - "Code", - "User", - "settings.json" + "Claude", + "claude_desktop_config.json" ), - mcpType = McpTypes.VSCode, + mcpType = McpTypes.ClaudeDesktop, configStatus = "Not Configured", }, + // 5) VSCode GitHub Copilot new() { - name = "Windsurf", + name = "VSCode GitHub Copilot", windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".codeium", - "windsurf", - "mcp_config.json" + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Code", + "User", + "settings.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".codeium", - "windsurf", - "mcp_config.json" + "Library", + "Application Support", + "Code", + "User", + "settings.json" ), - mcpType = McpTypes.Windsurf, + mcpType = McpTypes.VSCode, configStatus = "Not Configured", }, }; diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 626849bb..b7e4af25 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -45,17 +45,10 @@ public static void StartAutoConnect() try { - // Reuse stored project port when available to avoid port changes during setup + // Prefer stored project port and start using the robust Start() path (with retries/options) currentUnityPort = PortManager.GetPortWithFallback(); - - listener = new TcpListener(IPAddress.Loopback, currentUnityPort); - listener.Start(); - isRunning = true; + Start(); isAutoConnectMode = true; - - Debug.Log($"UnityMcpBridge auto-connected on port {currentUnityPort}"); - Task.Run(ListenerLoop); - EditorApplication.update += ProcessCommands; } catch (Exception ex) { @@ -226,11 +219,12 @@ public static void Stop() try { + // Mark as stopping early to avoid accept logging during disposal + isRunning = false; // Mark heartbeat one last time before stopping WriteHeartbeat(false); listener?.Stop(); listener = null; - isRunning = false; EditorApplication.update -= ProcessCommands; Debug.Log("UnityMcpBridge stopped."); } @@ -261,6 +255,14 @@ private static async Task ListenerLoop() // Fire and forget each client connection _ = HandleClientAsync(client); } + catch (ObjectDisposedException) + { + // Listener was disposed during stop/reload; exit quietly + if (!isRunning) + { + break; + } + } catch (Exception ex) { if (isRunning) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 34103c66..4a2a27ae 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -29,6 +29,7 @@ public class UnityMcpEditorWindow : EditorWindow private bool lastClientRegisteredOk; private bool lastBridgeVerifiedOk; private string pythonDirOverride = null; + private bool debugLogsEnabled; // Script validation settings private int validationLevelIndex = 1; // Default to Standard @@ -56,6 +57,7 @@ private void OnEnable() // Refresh bridge status isUnityBridgeRunning = UnityMcpBridge.IsRunning; autoRegisterEnabled = EditorPrefs.GetBool("UnityMCP.AutoRegisterEnabled", true); + debugLogsEnabled = EditorPrefs.GetBool("UnityMCP.DebugLogs", false); foreach (McpClient mcpClient in mcpClients.clients) { CheckMcpConfiguration(mcpClient); @@ -148,14 +150,50 @@ private void OnGUI() // Header DrawHeader(); - // Single-column streamlined layout - DrawServerStatusSection(); - EditorGUILayout.Space(6); - DrawBridgeSection(); - EditorGUILayout.Space(10); - DrawUnifiedClientConfiguration(); + // Compute equal column widths for uniform layout + float horizontalSpacing = 2f; + float outerPadding = 20f; // approximate padding + // Make columns a bit less wide for a tighter layout + float computed = (position.width - outerPadding - horizontalSpacing) / 2f; + float colWidth = Mathf.Clamp(computed, 220f, 340f); + // Use fixed heights per row so paired panels match exactly + float topPanelHeight = 190f; + float bottomPanelHeight = 230f; + + // Top row: Server Status (left) and Unity Bridge (right) + EditorGUILayout.BeginHorizontal(); + { + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); + DrawServerStatusSection(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(horizontalSpacing); + + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); + DrawBridgeSection(); + EditorGUILayout.EndVertical(); + } + EditorGUILayout.EndHorizontal(); + EditorGUILayout.Space(10); - DrawValidationSection(); + + // Second row: MCP Client Configuration (left) and Script Validation (right) + EditorGUILayout.BeginHorizontal(); + { + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); + DrawUnifiedClientConfiguration(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(horizontalSpacing); + + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); + DrawValidationSection(); + EditorGUILayout.EndVertical(); + } + EditorGUILayout.EndHorizontal(); + + // Minimal bottom padding + EditorGUILayout.Space(2); EditorGUILayout.EndScrollView(); } @@ -205,21 +243,12 @@ private void DrawServerStatusSection() EditorGUILayout.Space(5); - // Connection mode and Setup controls + // Connection mode EditorGUILayout.BeginHorizontal(); - bool isAutoMode = UnityMcpBridge.IsAutoConnectMode(); GUIStyle modeStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; EditorGUILayout.LabelField($"Mode: {(isAutoMode ? "Auto" : "Standard")}", modeStyle); - GUILayout.FlexibleSpace(); - - // Bind to Clients button - if (GUILayout.Button("Bind to Clients", GUILayout.Width(140), GUILayout.Height(24))) - { - RunSetupNow(); - } - EditorGUILayout.EndHorizontal(); // Current ports display @@ -231,6 +260,27 @@ private void DrawServerStatusSection() EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle); EditorGUILayout.Space(5); + // Auto-Setup button below ports + string setupButtonText = (lastClientRegisteredOk && lastBridgeVerifiedOk) ? "Connected ✓" : "Auto-Setup"; + if (GUILayout.Button(setupButtonText, GUILayout.Height(24))) + { + RunSetupNow(); + } + EditorGUILayout.Space(4); + + // Debug logs toggle inside Server Status + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.FlexibleSpace(); + bool newDebug = EditorGUILayout.ToggleLeft("Show Debug Logs", debugLogsEnabled, GUILayout.Width(150)); + if (newDebug != debugLogsEnabled) + { + debugLogsEnabled = newDebug; + EditorPrefs.SetBool("UnityMCP.DebugLogs", debugLogsEnabled); + } + } + EditorGUILayout.Space(2); + // Removed redundant inline badges to streamline UI // Troubleshooting helpers @@ -318,7 +368,18 @@ private void DrawValidationSection() EditorGUILayout.Space(8); string description = GetValidationLevelDescription(validationLevelIndex); EditorGUILayout.HelpBox(description, MessageType.Info); - EditorGUILayout.Space(5); + EditorGUILayout.Space(4); + // Inline debug logs toggle under Script Validation + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + bool newDebug = EditorGUILayout.ToggleLeft("Show Debug Logs", debugLogsEnabled, GUILayout.Width(150)); + if (newDebug != debugLogsEnabled) + { + debugLogsEnabled = newDebug; + EditorPrefs.SetBool("UnityMCP.DebugLogs", debugLogsEnabled); + } + EditorGUILayout.EndHorizontal(); + EditorGUILayout.Space(2); EditorGUILayout.EndVertical(); } @@ -870,7 +931,10 @@ private string FindPackagePythonDirectory() { if (Directory.Exists(devPath) && File.Exists(Path.Combine(devPath, "server.py"))) { - UnityEngine.Debug.Log($"Currently in development mode. Package: {devPath}"); + if (debugLogsEnabled) + { + UnityEngine.Debug.Log($"Currently in development mode. Package: {devPath}"); + } return devPath; } } @@ -931,7 +995,10 @@ private string FindPackagePythonDirectory() } // If still not found, return the placeholder path - UnityEngine.Debug.LogWarning("Could not find Python directory, using placeholder path"); + if (debugLogsEnabled) + { + UnityEngine.Debug.LogWarning("Could not find Python directory, using placeholder path"); + } } catch (Exception e) { @@ -1319,7 +1386,10 @@ private void RegisterWithClaudeCode(string pythonDir) } else if (!string.IsNullOrEmpty(errors)) { - UnityEngine.Debug.LogWarning($"Claude MCP errors: {errors}"); + if (debugLogsEnabled) + { + UnityEngine.Debug.LogWarning($"Claude MCP errors: {errors}"); + } } } catch (Exception e) @@ -1806,7 +1876,10 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath; - UnityEngine.Debug.Log($"Checking Claude config at: {configPath}"); + if (debugLogsEnabled) + { + UnityEngine.Debug.Log($"Checking Claude config at: {configPath}"); + } if (!File.Exists(configPath)) { From cf86964d4dd2ca26a1af934441f9a7626bfece53 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 8 Aug 2025 14:58:41 -0400 Subject: [PATCH 19/69] chore: revise documentation --- README.md | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 94649489..2fc9af69 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,19 @@ # Unity MCP ✨ +#### Proudly sponsored and maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp), the AI assistant for Unity. +[![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4) [![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive) [![python](https://img.shields.io/badge/Python-3.12-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org) [![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction) ![GitHub commit activity](https://img.shields.io/github/commit-activity/w/coplaydev/unity-mcp) ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/coplaydev/unity-mcp) [![](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT) +[![](https://img.shields.io/badge/Sponsor-Coplay-red.svg 'Coplay')](https://www.coplay.dev/?ref=unity-mcp) **Create your Unity apps with LLMs!** Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to interact directly with your Unity Editor via a local **MCP (Model Context Protocol) Client**. Give your LLM tools to manage assets, control scenes, edit scripts, and automate tasks within Unity. -Proudly sponsored by [Coplay](https://www.coplay.dev) - -

- - Coplay Logo - -

- ## 💬 Join Our Community ### [Discord](https://discord.gg/y4p8KfzrN4) @@ -335,13 +330,6 @@ Still stuck? [Open an Issue](https://github.com/coplaydev/unity-mcp/issues) or [ --- -## Contact 👋 - -- **justinpbarnett:** [X/Twitter](https://x.com/justinpbarnett) -- **scriptwonder**: [Email](mailto:swu85@ur.rochester.edu), [LinkedIn](https://www.linkedin.com/in/shutong-wu-214043172/) - ---- - ## License 📜 MIT License. See [LICENSE](LICENSE) file. @@ -350,8 +338,17 @@ MIT License. See [LICENSE](LICENSE) file. ## Acknowledgments 🙏 -Thanks to the contributors and our sponsors [Coplay](https://coplay.dev). +Thanks to the contributors and our sponsors [Coplay](https://coplay.dev/?ref=unity-mcp). ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=coplaydev/unity-mcp&type=Date)](https://www.star-history.com/#coplaydev/unity-mcp&Date) + + +## Sponsor + +

+ + Coplay Logo + +

\ No newline at end of file From 6faa55f8253935f160c2446f933562d5883be56a Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 8 Aug 2025 14:59:30 -0400 Subject: [PATCH 20/69] chore: update package namespace from justinpbarnett to coplaydev --- README-DEV.md | 2 +- UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs | 2 +- UnityMcpBridge/package.json | 4 ++-- deploy-dev.bat | 2 +- restore-dev.bat | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README-DEV.md b/README-DEV.md index 954348ba..398bdab2 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -48,7 +48,7 @@ Restores original files from backup. Unity package cache is typically located at: ``` -X:\UnityProject\Library\PackageCache\com.justinpbarnett.unity-mcp@1.0.0 +X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 ``` To find it: diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 14c9f5b6..13fadf36 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -626,7 +626,7 @@ private string FindPackagePythonDirectory() { foreach (UnityEditor.PackageManager.PackageInfo package in request.Result) { - if (package.name == "com.justinpbarnett.unity-mcp") + if (package.name == "com.coplaydev.unity-mcp") { string packagePath = package.resolvedPath; diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index d1ee0081..2fd87999 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,6 +1,6 @@ { - "name": "com.justinpbarnett.unity-mcp", - "version": "2.0.0", + "name": "com.coplaydev.unity-mcp", + "version": "1.0.0", "displayName": "Unity MCP Bridge", "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", "unity": "2020.3", diff --git a/deploy-dev.bat b/deploy-dev.bat index 4cc61de9..6a83fcf0 100644 --- a/deploy-dev.bat +++ b/deploy-dev.bat @@ -19,7 +19,7 @@ echo. :: Package cache location echo Unity Package Cache Location: -echo Example: X:\UnityProject\Library\PackageCache\com.justinpbarnett.unity-mcp@1.0.0 +echo Example: X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " if "%PACKAGE_CACHE_PATH%"=="" ( diff --git a/restore-dev.bat b/restore-dev.bat index a8e327ad..69d9312b 100644 --- a/restore-dev.bat +++ b/restore-dev.bat @@ -16,7 +16,7 @@ echo. :: Package cache location echo Unity Package Cache Location: -echo Example: X:\UnityProject\Library\PackageCache\com.justinpbarnett.unity-mcp@1.0.0 +echo Example: X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " if "%PACKAGE_CACHE_PATH%"=="" ( From 5d148a746272445d3a0a165c879abc861f8bec36 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 8 Aug 2025 15:06:35 -0400 Subject: [PATCH 21/69] chore: update repository URLs and package name to use correct CoplayDev casing and main branch --- README-DEV.md | 2 +- README.md | 12 ++++++------ UnityMcpBridge/Editor/Helpers/ServerInstaller.cs | 6 +++--- .../Editor/Windows/UnityMcpEditorWindow.cs | 2 +- UnityMcpBridge/package.json | 2 +- deploy-dev.bat | 2 +- restore-dev.bat | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README-DEV.md b/README-DEV.md index 398bdab2..be9e022c 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -48,7 +48,7 @@ Restores original files from backup. Unity package cache is typically located at: ``` -X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 +X:\UnityProject\Library\PackageCache\com.CoplayDev.unity-mcp@1.0.0 ``` To find it: diff --git a/README.md b/README.md index 2fc9af69..d82c6f46 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ [![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive) [![python](https://img.shields.io/badge/Python-3.12-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org) [![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction) -![GitHub commit activity](https://img.shields.io/github/commit-activity/w/coplaydev/unity-mcp) -![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/coplaydev/unity-mcp) +![GitHub commit activity](https://img.shields.io/github/commit-activity/w/CoplayDev/unity-mcp) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/CoplayDev/unity-mcp) [![](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT) [![](https://img.shields.io/badge/Sponsor-Coplay-red.svg 'Coplay')](https://www.coplay.dev/?ref=unity-mcp) @@ -109,7 +109,7 @@ Unity MCP connects your tools using two components: 3. Click `+` -> `Add package from git URL...`. 4. Enter: ``` - https://github.com/coplaydev/unity-mcp.git?path=/UnityMcpBridge + https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge ``` 5. Click `Add`. 6. The MCP Server should automatically be installed onto your machine as a result of this process. @@ -292,7 +292,7 @@ Help make Unity MCP better! 5. **Push** your branch. -6. **Open a Pull Request** against the master branch. +6. **Open a Pull Request** against the main branch. --- @@ -326,7 +326,7 @@ Help make Unity MCP better! -Still stuck? [Open an Issue](https://github.com/coplaydev/unity-mcp/issues) or [Join the Discord](https://discord.gg/y4p8KfzrN4)! +Still stuck? [Open an Issue](https://github.com/CoplayDev/unity-mcp/issues) or [Join the Discord](https://discord.gg/y4p8KfzrN4)! --- @@ -342,7 +342,7 @@ Thanks to the contributors and our sponsors [Coplay](https://coplay.dev/?ref=uni ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=coplaydev/unity-mcp&type=Date)](https://www.star-history.com/#coplaydev/unity-mcp&Date) +[![Star History Chart](https://api.star-history.com/svg?repos=CoplayDev/unity-mcp&type=Date)](https://www.star-history.com/#CoplayDev/unity-mcp&Date) ## Sponsor diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 9d5682f4..6fd05bcc 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -11,10 +11,10 @@ public static class ServerInstaller { private const string RootFolder = "UnityMCP"; private const string ServerFolder = "UnityMcpServer"; - private const string BranchName = "master"; - private const string GitUrl = "https://github.com/justinpbarnett/unity-mcp.git"; + private const string BranchName = "main"; + private const string GitUrl = "https://github.com/CoplayDev/unity-mcp.git"; private const string PyprojectUrl = - "https://raw.githubusercontent.com/justinpbarnett/unity-mcp/refs/heads/" + "https://raw.githubusercontent.com/CoplayDev/unity-mcp/refs/heads/" + BranchName + "/UnityMcpServer/src/pyproject.toml"; diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 13fadf36..35c19b05 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -626,7 +626,7 @@ private string FindPackagePythonDirectory() { foreach (UnityEditor.PackageManager.PackageInfo package in request.Result) { - if (package.name == "com.coplaydev.unity-mcp") + if (package.name == "com.CoplayDev.unity-mcp") { string packagePath = package.resolvedPath; diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index 2fd87999..62741fe8 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,5 +1,5 @@ { - "name": "com.coplaydev.unity-mcp", + "name": "com.CoplayDev.unity-mcp", "version": "1.0.0", "displayName": "Unity MCP Bridge", "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", diff --git a/deploy-dev.bat b/deploy-dev.bat index 6a83fcf0..90890e6d 100644 --- a/deploy-dev.bat +++ b/deploy-dev.bat @@ -19,7 +19,7 @@ echo. :: Package cache location echo Unity Package Cache Location: -echo Example: X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 +echo Example: X:\UnityProject\Library\PackageCache\com.CoplayDev.unity-mcp@1.0.0 set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " if "%PACKAGE_CACHE_PATH%"=="" ( diff --git a/restore-dev.bat b/restore-dev.bat index 69d9312b..c740afca 100644 --- a/restore-dev.bat +++ b/restore-dev.bat @@ -16,7 +16,7 @@ echo. :: Package cache location echo Unity Package Cache Location: -echo Example: X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 +echo Example: X:\UnityProject\Library\PackageCache\com.CoplayDev.unity-mcp@1.0.0 set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " if "%PACKAGE_CACHE_PATH%"=="" ( From 94df819e613c7f80fb178c8ad5fd966a6494739c Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 8 Aug 2025 15:13:47 -0400 Subject: [PATCH 22/69] fix: update package name from com.CoplayDev.unity-mcp to com.coplaydev.unity-mcp to comply with Unity package naming standards --- README-DEV.md | 2 +- UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs | 2 +- UnityMcpBridge/package.json | 2 +- deploy-dev.bat | 2 +- restore-dev.bat | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README-DEV.md b/README-DEV.md index be9e022c..398bdab2 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -48,7 +48,7 @@ Restores original files from backup. Unity package cache is typically located at: ``` -X:\UnityProject\Library\PackageCache\com.CoplayDev.unity-mcp@1.0.0 +X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 ``` To find it: diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 35c19b05..13fadf36 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -626,7 +626,7 @@ private string FindPackagePythonDirectory() { foreach (UnityEditor.PackageManager.PackageInfo package in request.Result) { - if (package.name == "com.CoplayDev.unity-mcp") + if (package.name == "com.coplaydev.unity-mcp") { string packagePath = package.resolvedPath; diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index 62741fe8..2fd87999 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,5 +1,5 @@ { - "name": "com.CoplayDev.unity-mcp", + "name": "com.coplaydev.unity-mcp", "version": "1.0.0", "displayName": "Unity MCP Bridge", "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", diff --git a/deploy-dev.bat b/deploy-dev.bat index 90890e6d..6a83fcf0 100644 --- a/deploy-dev.bat +++ b/deploy-dev.bat @@ -19,7 +19,7 @@ echo. :: Package cache location echo Unity Package Cache Location: -echo Example: X:\UnityProject\Library\PackageCache\com.CoplayDev.unity-mcp@1.0.0 +echo Example: X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " if "%PACKAGE_CACHE_PATH%"=="" ( diff --git a/restore-dev.bat b/restore-dev.bat index c740afca..69d9312b 100644 --- a/restore-dev.bat +++ b/restore-dev.bat @@ -16,7 +16,7 @@ echo. :: Package cache location echo Unity Package Cache Location: -echo Example: X:\UnityProject\Library\PackageCache\com.CoplayDev.unity-mcp@1.0.0 +echo Example: X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " if "%PACKAGE_CACHE_PATH%"=="" ( From 823bae624fb9131f539b89b7a5ee665ed47c4a7b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 8 Aug 2025 15:19:11 -0400 Subject: [PATCH 23/69] docs: fix escape character in README error message --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d82c6f46..fd68ef91 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte * `read_console`: Gets messages from or clears the console. * `manage_script`: Manages C# scripts (create, read, update, delete). - * `manage_editor`: Controls and queries the editor's state and settings. + * `manage_editor`: Controls and queries the editor\'s state and settings. * `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.). * `manage_asset`: Performs asset operations (import, create, modify, delete, etc.). * `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). @@ -124,13 +124,13 @@ Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installe 1. In Unity, go to `Window > Unity MCP`. 2. Click `Auto Configure` on the IDE you uses. -3. Look for a green status indicator 🟢 and "Connected". *(This attempts to modify the MCP Client's config file automatically)*. +3. Look for a green status indicator 🟢 and "Connected". *(This attempts to modify the MCP Client\'s config file automatically)*. **Option B: Manual Configuration** If Auto-Configure fails or you use a different client: -1. **Find your MCP Client's configuration file.** (Check client documentation). +1. **Find your MCP Client\'s configuration file.** (Check client documentation). * *Claude Example (macOS):* `~/Library/Application Support/Claude/claude_desktop_config.json` * *Claude Example (Windows):* `%APPDATA%\Claude\claude_desktop_config.json` 2. **Edit the file** to add/update the `mcpServers` section, using the *exact* paths from Step 1. @@ -204,7 +204,7 @@ If Auto-Configure fails or you use a different client: **For Claude Code** -If you're using Claude Code, you can register the MCP server using these commands: +If you\'re using Claude Code, you can register the MCP server using these commands: **macOS:** ```bash @@ -269,7 +269,7 @@ claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/S ### Development Tools -If you're contributing to Unity MCP or want to test core changes, we have development tools to streamline your workflow: +If you\'re contributing to Unity MCP or want to test core changes, we have development tools to streamline your workflow: - **Development Deployment Scripts**: Quickly deploy and test your changes to Unity MCP Bridge and Python Server - **Automatic Backup System**: Safe testing with easy rollback capabilities @@ -311,7 +311,7 @@ Help make Unity MCP better! - **MCP Client Not Connecting / Server Not Starting:** - - **Verify Server Path:** Double-check the --directory path in your MCP Client's JSON config. It must exactly match the location where you cloned the UnityMCP repository in Installation Step 1 (e.g., .../Programs/UnityMCP/UnityMcpServer/src). + - **Verify Server Path:** Double-check the --directory path in your MCP Client\'s JSON config. It must exactly match the location where you cloned the UnityMCP repository in Installation Step 1 (e.g., .../Programs/UnityMCP/UnityMcpServer/src). - **Verify uv:** Make sure `uv` is installed and working (pip show uv). @@ -321,7 +321,7 @@ Help make Unity MCP better! - **Auto-Configure Failed:** - - Use the Manual Configuration steps. Auto-configure might lack permissions to write to the MCP client's config file. + - Use the Manual Configuration steps. Auto-configure might lack permissions to write to the MCP client\'s config file. From 7087797952c8d32504fe35b1e4a8e5104a3a301f Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 8 Aug 2025 15:39:12 -0400 Subject: [PATCH 24/69] docs: improve README formatting and add note about package reinstallation --- README.md | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index fd68ef91..0dea158c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Unity MCP ✨ + #### Proudly sponsored and maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp), the AI assistant for Unity. [![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4) @@ -20,10 +21,8 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte **Get help, share ideas, and collaborate with other Unity MCP developers!** - --- - ## Key Features 🚀 * **🗣️ Natural Language Control:** Instruct your LLM to perform Unity tasks. @@ -65,7 +64,6 @@ Unity MCP connects your tools using two components: ### Prerequisites - * **Git CLI:** For cloning the server code. [Download Git](https://git-scm.com/downloads) * **Python:** Version 3.12 or newer. [Download Python](https://www.python.org/downloads/) * **Unity Hub & Editor:** Version 2020.3 LTS or newer. [Download Unity](https://unity.com/download) @@ -98,9 +96,8 @@ Unity MCP connects your tools using two components: 3. Ensure .NET compatibility settings are correct 4. Add `USE_ROSLYN` to Scripting Define Symbols 5. Restart Unity - - **Note:** Without Roslyn, script validation falls back to basic structural checks. Roslyn enables full C# compiler diagnostics with precise error reporting. + **Note:** Without Roslyn, script validation falls back to basic structural checks. Roslyn enables full C# compiler diagnostics with precise error reporting. ### Step 1: Install the Unity Package (Bridge) @@ -114,6 +111,8 @@ Unity MCP connects your tools using two components: 5. Click `Add`. 6. The MCP Server should automatically be installed onto your machine as a result of this process. +**Note:** If you installed the MCP Server before Coplay's maintenance, you will need to uninstall the old package before re-installing the new one. + ### Step 2: Configure Your MCP Client Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installed in Step 1. @@ -177,6 +176,7 @@ If Auto-Configure fails or you use a different client: } } ``` + (Replace YOUR_USERNAME if using ~/bin) **Linux:** @@ -200,18 +200,18 @@ If Auto-Configure fails or you use a different client: (Replace YOUR_USERNAME) - - **For Claude Code** If you\'re using Claude Code, you can register the MCP server using these commands: **macOS:** + ```bash claude mcp add UnityMCP -- uv --directory /[PATH_TO]/UnityMCP/UnityMcpServer/src run server.py ``` **Windows:** + ```bash claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/Scripts/uv.exe" --directory "C:/Users/USERNAME/AppData/Local/Programs/UnityMCP/UnityMcpServer/src" run server.py ``` @@ -228,12 +228,13 @@ claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/S 3. **Interact!** Unity tools should now be available in your MCP Client. Example Prompt: `Create a 3D player controller`, `Create a yellow and bridge sun`, `Create a cool shader and apply it on a cube`. - + --- ## Future Dev Plans (Besides PR) 📝 ### 🔴 High Priority + - [ ] **Asset Generation Improvements** - Enhanced server request handling and asset pipeline optimization - [ ] **Code Generation Enhancements** - Improved generated code quality and error handling - [ ] **Robust Error Handling** - Comprehensive error messages, recovery mechanisms, and graceful degradation @@ -241,10 +242,12 @@ claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/S - [ ] **Documentation Expansion** - Complete tutorials for custom tool creation and API reference ### 🟡 Medium Priority + - [ ] **Custom Tool Creation GUI** - Visual interface for users to create and configure their own MCP tools - [ ] **Advanced Logging System** - Logging with filtering, export, and debugging capabilities ### 🟢 Low Priority + - [ ] **Mobile Platform Support** - Extended toolset for mobile development workflows and platform-specific features - [ ] **Easier Tool Setup** - [ ] **Plugin Marketplace** - Community-driven tool sharing and distribution platform @@ -257,6 +260,7 @@ claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/S ### 🔬 Research & Exploration + - [ ] **AI-Powered Asset Generation** - Integration with AI tools for automatic 3D models, textures, and animations - [ ] **Real-time Collaboration** - Live editing sessions between multiple developers *(Currently in progress)* - [ ] **Analytics Dashboard** - Usage analytics, project insights, and performance metrics @@ -302,28 +306,17 @@ Help make Unity MCP better! Click to view common issues and fixes... - **Unity Bridge Not Running/Connecting:** - - Ensure Unity Editor is open. - - Check the status window: Window > Unity MCP. - - Restart Unity. - - **MCP Client Not Connecting / Server Not Starting:** - - **Verify Server Path:** Double-check the --directory path in your MCP Client\'s JSON config. It must exactly match the location where you cloned the UnityMCP repository in Installation Step 1 (e.g., .../Programs/UnityMCP/UnityMcpServer/src). - - **Verify uv:** Make sure `uv` is installed and working (pip show uv). - - **Run Manually:** Try running the server directly from the terminal to see errors: `# Navigate to the src directory first! cd /path/to/your/UnityMCP/UnityMcpServer/src uv run server.py` - - **Permissions (macOS/Linux):** If you installed the server in a system location like /usr/local/bin, ensure the user running the MCP client has permission to execute uv and access files there. Installing in ~/bin might be easier. - - **Auto-Configure Failed:** - - Use the Manual Configuration steps. Auto-configure might lack permissions to write to the MCP client\'s config file. - Still stuck? [Open an Issue](https://github.com/CoplayDev/unity-mcp/issues) or [Join the Discord](https://discord.gg/y4p8KfzrN4)! @@ -336,19 +329,14 @@ MIT License. See [LICENSE](LICENSE) file. --- -## Acknowledgments 🙏 - -Thanks to the contributors and our sponsors [Coplay](https://coplay.dev/?ref=unity-mcp). - ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=CoplayDev/unity-mcp&type=Date)](https://www.star-history.com/#CoplayDev/unity-mcp&Date) - ## Sponsor

Coplay Logo -

\ No newline at end of file +

From 49b0a5397dba1e853d51d6e305557948580b9f55 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 8 Aug 2025 15:42:52 -0400 Subject: [PATCH 25/69] docs: add Windsurf to list of supported code editors --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0dea158c..b05d7d4f 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Unity MCP connects your tools using two components: * [Claude Code](https://github.com/anthropics/claude-code) * [Cursor](https://www.cursor.com/en/downloads) * [Visual Studio Code Copilot](https://code.visualstudio.com/docs/copilot/overview) + * [Windsurf](https://windsurf.com) * *(Others may work with manual config)* *
[Optional] Roslyn for Advanced Script Validation From 3288418bbfcfa39cfc6b22daaaf6b6ca1ebd7c81 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 8 Aug 2025 15:54:43 -0400 Subject: [PATCH 26/69] chore: update package metadata and license to CoplayDev organization --- LICENSE | 2 +- UnityMcpBridge/package.json | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index ebeecf5b..e7f878d1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Justin P Barnett +Copyright (c) 2025 CoplayDev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index 2fd87999..bbb5994c 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -4,7 +4,23 @@ "displayName": "Unity MCP Bridge", "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", "unity": "2020.3", + "documentationUrl": "https://github.com/CoplayDev/unity-mcp", + "licensesUrl": "https://github.com/CoplayDev/unity-mcp/blob/main/LICENSE", "dependencies": { "com.unity.nuget.newtonsoft-json": "3.0.2" + }, + "keywords": [ + "unity", + "ai", + "llm", + "mcp", + "model-context-protocol", + "mcp-server", + "mcp-client" + ], + "author": { + "name": "CoplayDev", + "email": "support@coplay.dev", + "url": "https://coplay.dev" } } From f24e124c1565036ec50d80007a43aa7e2daf112e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 8 Aug 2025 14:16:25 -0700 Subject: [PATCH 27/69] MCP: Embedded server reliability and UX\n\n- Embed-first installer: copies embedded server, adds RepairPythonEnvironment() (deletes .venv, runs 'uv sync'); robust uv path discovery; macOS install path -> Application Support\n- UI: Server Status shows Installed(Embedded); Python missing warning with install link; Repair button tooltip; header Show Debug Logs; cleaned layout\n- Python: unpin .python-version; set requires-python >=3.10 in both pyprojects\n- Dev: improved package/dev path resolution --- .../Editor/Helpers/ServerInstaller.cs | 197 +++++++++++++++++- .../Editor/Windows/UnityMcpEditorWindow.cs | 139 +++++++++--- .../UnityMcpServer/src/.python-version | 1 - .../UnityMcpServer/src/pyproject.toml | 2 +- UnityMcpServer/src/.python-version | 1 - UnityMcpServer/src/pyproject.toml | 2 +- UnityMcpServer/src/uv.lock | 53 ++++- 7 files changed, 356 insertions(+), 39 deletions(-) delete mode 100644 UnityMcpBridge/UnityMcpServer/src/.python-version delete mode 100644 UnityMcpServer/src/.python-version diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 56b23430..ed92786a 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -75,14 +75,11 @@ private static string GetSaveLocation() } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - string path = "/usr/local/bin"; - return !Directory.Exists(path) || !IsDirectoryWritable(path) - ? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Applications", - RootFolder - ) - : Path.Combine(path, RootFolder); + // Use Application Support for a stable, user-writable location + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "UnityMCP" + ); } throw new Exception("Unsupported operating system."); } @@ -213,5 +210,189 @@ private static void CopyDirectoryRecursive(string sourceDir, string destinationD CopyDirectoryRecursive(dirPath, destSubDir); } } + + public static bool RepairPythonEnvironment() + { + try + { + string serverSrc = GetServerPath(); + bool hasServer = File.Exists(Path.Combine(serverSrc, "server.py")); + if (!hasServer) + { + // In dev mode or if not installed yet, try the embedded/dev source + if (TryGetEmbeddedServerSource(out string embeddedSrc) && File.Exists(Path.Combine(embeddedSrc, "server.py"))) + { + serverSrc = embeddedSrc; + hasServer = true; + } + else + { + // Attempt to install then retry + EnsureServerInstalled(); + serverSrc = GetServerPath(); + hasServer = File.Exists(Path.Combine(serverSrc, "server.py")); + } + } + + if (!hasServer) + { + Debug.LogWarning("RepairPythonEnvironment: server.py not found; ensure server is installed first."); + return false; + } + + // Remove stale venv and pinned version file if present + string venvPath = Path.Combine(serverSrc, ".venv"); + if (Directory.Exists(venvPath)) + { + try { Directory.Delete(venvPath, recursive: true); } catch (Exception ex) { Debug.LogWarning($"Failed to delete .venv: {ex.Message}"); } + } + string pyPin = Path.Combine(serverSrc, ".python-version"); + if (File.Exists(pyPin)) + { + try { File.Delete(pyPin); } catch (Exception ex) { Debug.LogWarning($"Failed to delete .python-version: {ex.Message}"); } + } + + string uvPath = FindUvPath(); + if (uvPath == null) + { + Debug.LogError("UV not found. Please install uv (https://docs.astral.sh/uv/)." ); + return false; + } + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = uvPath, + Arguments = "sync", + WorkingDirectory = serverSrc, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var p = System.Diagnostics.Process.Start(psi); + string stdout = p.StandardOutput.ReadToEnd(); + string stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(60000); + + if (p.ExitCode != 0) + { + Debug.LogError($"uv sync failed: {stderr}\n{stdout}"); + return false; + } + + Debug.Log("Unity MCP: Python environment repaired successfully."); + return true; + } + catch (Exception ex) + { + Debug.LogError($"RepairPythonEnvironment failed: {ex.Message}"); + return false; + } + } + + private static string FindUvPath() + { + // Allow user override via EditorPrefs + try + { + string overridePath = EditorPrefs.GetString("UnityMCP.UvPath", string.Empty); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + if (ValidateUvBinary(overridePath)) return overridePath; + } + } + catch { } + + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/opt/homebrew/bin/uv", + "/usr/local/bin/uv", + "/usr/bin/uv", + "/opt/local/bin/uv", + Path.Combine(home, ".local", "bin", "uv"), + "/opt/homebrew/opt/uv/bin/uv", + // Framework Python installs + "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv", + // Fallback to PATH resolution by name + "uv" + }; + foreach (string c in candidates) + { + try + { + if (ValidateUvBinary(c)) return c; + } + catch { /* ignore */ } + } + + // Try which uv (explicit path) + try + { + var whichPsi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = "uv", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var wp = System.Diagnostics.Process.Start(whichPsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(3000); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + { + if (ValidateUvBinary(output)) return output; + } + } + catch { } + + // Manual PATH scan + try + { + string pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + string[] parts = pathEnv.Split(Path.PathSeparator); + foreach (string part in parts) + { + try + { + string candidate = Path.Combine(part, "uv"); + if (File.Exists(candidate) && ValidateUvBinary(candidate)) return candidate; + } + catch { } + } + } + catch { } + + return null; + } + + private static bool ValidateUvBinary(string uvPath) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = uvPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + if (!p.WaitForExit(5000)) { try { p.Kill(); } catch { } return false; } + if (p.ExitCode == 0) + { + string output = p.StandardOutput.ReadToEnd().Trim(); + return output.StartsWith("uv "); + } + } + catch { } + return false; + } } } diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 4a2a27ae..408a63b5 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -102,14 +102,32 @@ private Color GetStatusColor(McpStatus status) private void UpdatePythonServerInstallationStatus() { - string serverPath = ServerInstaller.GetServerPath(); - - if (File.Exists(Path.Combine(serverPath, "server.py"))) + try { - pythonServerInstallationStatus = "Installed (Embedded)"; - pythonServerInstallationStatusColor = Color.green; + string installedPath = ServerInstaller.GetServerPath(); + bool installedOk = !string.IsNullOrEmpty(installedPath) && File.Exists(Path.Combine(installedPath, "server.py")); + if (installedOk) + { + pythonServerInstallationStatus = "Installed"; + pythonServerInstallationStatusColor = Color.green; + return; + } + + // Fall back to embedded/dev source via our existing resolution logic + string embeddedPath = FindPackagePythonDirectory(); + bool embeddedOk = !string.IsNullOrEmpty(embeddedPath) && File.Exists(Path.Combine(embeddedPath, "server.py")); + if (embeddedOk) + { + pythonServerInstallationStatus = "Installed (Embedded)"; + pythonServerInstallationStatusColor = Color.green; + } + else + { + pythonServerInstallationStatus = "Not Installed"; + pythonServerInstallationStatusColor = Color.red; + } } - else + catch { pythonServerInstallationStatus = "Not Installed"; pythonServerInstallationStatusColor = Color.red; @@ -215,6 +233,16 @@ private void DrawHeader() "Unity MCP Editor", titleStyle ); + + // Place the Show Debug Logs toggle on the same header row, right-aligned + float toggleWidth = 160f; + Rect toggleRect = new Rect(titleRect.xMax - toggleWidth - 12f, titleRect.y + 10f, toggleWidth, 20f); + bool newDebug = GUI.Toggle(toggleRect, debugLogsEnabled, "Show Debug Logs"); + if (newDebug != debugLogsEnabled) + { + debugLogsEnabled = newDebug; + EditorPrefs.SetBool("UnityMCP.DebugLogs", debugLogsEnabled); + } EditorGUILayout.Space(15); } @@ -243,7 +271,6 @@ private void DrawServerStatusSection() EditorGUILayout.Space(5); - // Connection mode EditorGUILayout.BeginHorizontal(); bool isAutoMode = UnityMcpBridge.IsAutoConnectMode(); GUIStyle modeStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; @@ -251,7 +278,6 @@ private void DrawServerStatusSection() GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); - // Current ports display int currentUnityPort = UnityMcpBridge.GetCurrentPort(); GUIStyle portStyle = new GUIStyle(EditorStyles.miniLabel) { @@ -260,7 +286,7 @@ private void DrawServerStatusSection() EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle); EditorGUILayout.Space(5); - // Auto-Setup button below ports + /// Auto-Setup button below ports string setupButtonText = (lastClientRegisteredOk && lastBridgeVerifiedOk) ? "Connected ✓" : "Auto-Setup"; if (GUILayout.Button(setupButtonText, GUILayout.Height(24))) { @@ -268,20 +294,47 @@ private void DrawServerStatusSection() } EditorGUILayout.Space(4); - // Debug logs toggle inside Server Status + // Repair Python Env button with tooltip tag using (new EditorGUILayout.HorizontalScope()) { GUILayout.FlexibleSpace(); - bool newDebug = EditorGUILayout.ToggleLeft("Show Debug Logs", debugLogsEnabled, GUILayout.Width(150)); - if (newDebug != debugLogsEnabled) + GUIContent repairLabel = new GUIContent( + "Repair Python Env", + "Deletes the server's .venv and runs 'uv sync' to rebuild a clean environment. Use this if modules are missing or Python upgraded." + ); + if (GUILayout.Button(repairLabel, GUILayout.Width(160), GUILayout.Height(22))) { - debugLogsEnabled = newDebug; - EditorPrefs.SetBool("UnityMCP.DebugLogs", debugLogsEnabled); + bool ok = global::UnityMcpBridge.Editor.Helpers.ServerInstaller.RepairPythonEnvironment(); + if (ok) + { + EditorUtility.DisplayDialog("Unity MCP", "Python environment repaired.", "OK"); + UpdatePythonServerInstallationStatus(); + } + else + { + EditorUtility.DisplayDialog("Unity MCP", "Repair failed. Please check Console for details.", "OK"); + } } } + // (Removed descriptive tool tag under the Repair button) + + // (Show Debug Logs toggle moved to header) EditorGUILayout.Space(2); - // Removed redundant inline badges to streamline UI + // Python detection warning with link + if (!IsPythonDetected()) + { + GUIStyle warnStyle = new GUIStyle(EditorStyles.label) { richText = true, wordWrap = true }; + EditorGUILayout.LabelField("Warning: No Python installation found.", warnStyle); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Open install instructions", GUILayout.Width(200))) + { + Application.OpenURL("https://www.python.org/downloads/"); + } + } + EditorGUILayout.Space(4); + } // Troubleshooting helpers if (pythonServerInstallationStatusColor != Color.green) @@ -369,16 +422,7 @@ private void DrawValidationSection() string description = GetValidationLevelDescription(validationLevelIndex); EditorGUILayout.HelpBox(description, MessageType.Info); EditorGUILayout.Space(4); - // Inline debug logs toggle under Script Validation - EditorGUILayout.BeginHorizontal(); - GUILayout.FlexibleSpace(); - bool newDebug = EditorGUILayout.ToggleLeft("Show Debug Logs", debugLogsEnabled, GUILayout.Width(150)); - if (newDebug != debugLogsEnabled) - { - debugLogsEnabled = newDebug; - EditorPrefs.SetBool("UnityMCP.DebugLogs", debugLogsEnabled); - } - EditorGUILayout.EndHorizontal(); + // (Show Debug Logs toggle moved to header) EditorGUILayout.Space(2); EditorGUILayout.EndVertical(); } @@ -845,7 +889,10 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC return "Configured successfully"; } - private void ShowManualConfigurationInstructions(string configPath, McpClient mcpClient) + private void ShowManualConfigurationInstructions( + string configPath, + McpClient mcpClient + ) { mcpClient.SetStatus(McpStatus.Error, "Manual configuration required"); @@ -1938,5 +1985,45 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) mcpClient.SetStatus(McpStatus.Error, e.Message); } } + + private bool IsPythonDetected() + { + try + { + // Common absolute paths + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/opt/homebrew/bin/python3", + "/usr/local/bin/python3", + "/usr/bin/python3", + "/opt/local/bin/python3", + Path.Combine(home, ".local", "bin", "python3"), + "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", + }; + foreach (string c in candidates) + { + if (File.Exists(c)) return true; + } + + // Try 'which python3' + var psi = new ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = "python3", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + string outp = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(2000); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; + } + catch { } + return false; + } } } diff --git a/UnityMcpBridge/UnityMcpServer/src/.python-version b/UnityMcpBridge/UnityMcpServer/src/.python-version deleted file mode 100644 index e4fba218..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/UnityMcpBridge/UnityMcpServer/src/pyproject.toml b/UnityMcpBridge/UnityMcpServer/src/pyproject.toml index eebcde11..2c05fb83 100644 --- a/UnityMcpBridge/UnityMcpServer/src/pyproject.toml +++ b/UnityMcpBridge/UnityMcpServer/src/pyproject.toml @@ -3,7 +3,7 @@ name = "UnityMcpServer" version = "2.0.0" description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.10" dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"] [build-system] diff --git a/UnityMcpServer/src/.python-version b/UnityMcpServer/src/.python-version deleted file mode 100644 index e4fba218..00000000 --- a/UnityMcpServer/src/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/UnityMcpServer/src/pyproject.toml b/UnityMcpServer/src/pyproject.toml index eebcde11..2c05fb83 100644 --- a/UnityMcpServer/src/pyproject.toml +++ b/UnityMcpServer/src/pyproject.toml @@ -3,7 +3,7 @@ name = "UnityMcpServer" version = "2.0.0" description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.10" dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"] [build-system] diff --git a/UnityMcpServer/src/uv.lock b/UnityMcpServer/src/uv.lock index bc3e54ca..de0cd446 100644 --- a/UnityMcpServer/src/uv.lock +++ b/UnityMcpServer/src/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 1 -requires-python = ">=3.12" +requires-python = ">=3.10" [[package]] name = "annotated-types" @@ -16,6 +16,7 @@ name = "anyio" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -55,6 +56,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -179,6 +192,33 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, @@ -207,6 +247,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, ] [[package]] @@ -247,6 +296,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } wheels = [ @@ -342,6 +392,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } wheels = [ From 4c72309dc8669061c4bb25cbfde4836f61811c61 Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 8 Aug 2025 15:09:18 -0700 Subject: [PATCH 28/69] Bridge logs: add bold blue UNITY-MCP prefix; gate PortManager logs behind Debug Logs toggle; improve Python and UV detection on Windows (flex versions, where.exe/Path scan); tidy installer messages --- .../Editor/Helpers/PackageInstaller.cs | 8 +- UnityMcpBridge/Editor/Helpers/PortManager.cs | 21 ++-- .../Editor/Helpers/ServerInstaller.cs | 118 +++++++++++++----- UnityMcpBridge/Editor/UnityMcpBridge.cs | 6 +- .../Editor/Windows/UnityMcpEditorWindow.cs | 110 +++++++++++----- 5 files changed, 190 insertions(+), 73 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs index 75cdb3b9..ae420a26 100644 --- a/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs @@ -25,18 +25,18 @@ private static void InstallServerOnFirstLoad() { try { - Debug.Log("Unity MCP: Installing Python server..."); + Debug.Log("UNITY-MCP: Installing Python server..."); ServerInstaller.EnsureServerInstalled(); // Mark as installed EditorPrefs.SetBool(InstallationFlagKey, true); - Debug.Log("Unity MCP: Python server installation completed successfully."); + Debug.Log("UNITY-MCP: Python server installation completed successfully."); } catch (System.Exception ex) { - Debug.LogError($"Unity MCP: Failed to install Python server: {ex.Message}"); - Debug.LogWarning("Unity MCP: You may need to manually install the Python server. Check the Unity MCP Editor Window for instructions."); + Debug.LogError($"UNITY-MCP: Failed to install Python server: {ex.Message}"); + Debug.LogWarning("UNITY-MCP: You may need to manually install the Python server. Check the Unity MCP Editor Window for instructions."); } } } diff --git a/UnityMcpBridge/Editor/Helpers/PortManager.cs b/UnityMcpBridge/Editor/Helpers/PortManager.cs index 9caeccca..376f9163 100644 --- a/UnityMcpBridge/Editor/Helpers/PortManager.cs +++ b/UnityMcpBridge/Editor/Helpers/PortManager.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using UnityEditor; using System.Net; using System.Net.Sockets; using System.Security.Cryptography; @@ -15,6 +16,12 @@ namespace UnityMcpBridge.Editor.Helpers ///
public static class PortManager { + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("UnityMCP.DebugLogs", false); } + catch { return false; } + } + private const int DefaultPort = 6400; private const int MaxPortAttempts = 100; private const string RegistryFileName = "unity-mcp-port.json"; @@ -41,7 +48,7 @@ public static int GetPortWithFallback() string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && IsPortAvailable(storedConfig.unity_port)) { - Debug.Log($"Using stored port {storedConfig.unity_port} for current project"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Using stored port {storedConfig.unity_port} for current project"); return storedConfig.unity_port; } @@ -50,7 +57,7 @@ public static int GetPortWithFallback() { if (WaitForPortRelease(storedConfig.unity_port, 1500)) { - Debug.Log($"Stored port {storedConfig.unity_port} became available after short wait"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Stored port {storedConfig.unity_port} became available after short wait"); return storedConfig.unity_port; } // Prefer sticking to the same port; let the caller handle bind retries/fallbacks @@ -71,7 +78,7 @@ public static int DiscoverNewPort() { int newPort = FindAvailablePort(); SavePort(newPort); - Debug.Log($"Discovered and saved new port: {newPort}"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Discovered and saved new port: {newPort}"); return newPort; } @@ -84,18 +91,18 @@ private static int FindAvailablePort() // Always try default port first if (IsPortAvailable(DefaultPort)) { - Debug.Log($"Using default port {DefaultPort}"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Using default port {DefaultPort}"); return DefaultPort; } - Debug.Log($"Default port {DefaultPort} is in use, searching for alternative..."); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Default port {DefaultPort} is in use, searching for alternative..."); // Search for alternatives for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) { if (IsPortAvailable(port)) { - Debug.Log($"Found available port {port}"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Found available port {port}"); return port; } } @@ -204,7 +211,7 @@ private static void SavePort(int port) string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); File.WriteAllText(legacy, json); - Debug.Log($"Saved port {port} to storage"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Saved port {port} to storage"); } catch (Exception ex) { diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index ed92786a..03b753f2 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -281,7 +281,7 @@ public static bool RepairPythonEnvironment() return false; } - Debug.Log("Unity MCP: Python environment repaired successfully."); + Debug.Log("UNITY-MCP: Python environment repaired successfully."); return true; } catch (Exception ex) @@ -305,47 +305,100 @@ private static string FindUvPath() catch { } string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = + + // Platform-specific candidate lists + string[] candidates; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + candidates = new[] + { + // Common per-user installs + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python313\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python312\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python311\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python310\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python313\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python312\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python311\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python310\Scripts\uv.exe"), + // Program Files style installs (if a native installer was used) + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty, @"uv\uv.exe"), + // Try simple name resolution later via PATH + "uv.exe", + "uv" + }; + } + else { - "/opt/homebrew/bin/uv", - "/usr/local/bin/uv", - "/usr/bin/uv", - "/opt/local/bin/uv", - Path.Combine(home, ".local", "bin", "uv"), - "/opt/homebrew/opt/uv/bin/uv", - // Framework Python installs - "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv", - // Fallback to PATH resolution by name - "uv" - }; + candidates = new[] + { + "/opt/homebrew/bin/uv", + "/usr/local/bin/uv", + "/usr/bin/uv", + "/opt/local/bin/uv", + Path.Combine(home, ".local", "bin", "uv"), + "/opt/homebrew/opt/uv/bin/uv", + // Framework Python installs + "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv", + // Fallback to PATH resolution by name + "uv" + }; + } + foreach (string c in candidates) { try { - if (ValidateUvBinary(c)) return c; + if (File.Exists(c) && ValidateUvBinary(c)) return c; } catch { /* ignore */ } } - // Try which uv (explicit path) + // Use platform-appropriate which/where to resolve from PATH try { - var whichPsi = new System.Diagnostics.ProcessStartInfo + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - FileName = "/usr/bin/which", - Arguments = "uv", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var wp = System.Diagnostics.Process.Start(whichPsi); - string output = wp.StandardOutput.ReadToEnd().Trim(); - wp.WaitForExit(3000); - if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + var wherePsi = new System.Diagnostics.ProcessStartInfo + { + FileName = "where", + Arguments = "uv.exe", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var wp = System.Diagnostics.Process.Start(wherePsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(3000); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + string path = line.Trim(); + if (File.Exists(path) && ValidateUvBinary(path)) return path; + } + } + } + else { - if (ValidateUvBinary(output)) return output; + var whichPsi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = "uv", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var wp = System.Diagnostics.Process.Start(whichPsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(3000); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + { + if (ValidateUvBinary(output)) return output; + } } } catch { } @@ -359,8 +412,11 @@ private static string FindUvPath() { try { - string candidate = Path.Combine(part, "uv"); - if (File.Exists(candidate) && ValidateUvBinary(candidate)) return candidate; + // Check both uv and uv.exe + string candidateUv = Path.Combine(part, "uv"); + string candidateUvExe = Path.Combine(part, "uv.exe"); + if (File.Exists(candidateUv) && ValidateUvBinary(candidateUv)) return candidateUv; + if (File.Exists(candidateUvExe) && ValidateUvBinary(candidateUvExe)) return candidateUvExe; } catch { } } diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index b7e4af25..89bae4e5 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -128,7 +128,7 @@ public static void Start() // Don't restart if already running on a working port if (isRunning && listener != null) { - Debug.Log($"UnityMcpBridge already running on port {currentUnityPort}"); + Debug.Log($"UNITY-MCP: UnityMcpBridge already running on port {currentUnityPort}"); return; } @@ -194,7 +194,7 @@ public static void Start() isRunning = true; isAutoConnectMode = false; - Debug.Log($"UnityMcpBridge started on port {currentUnityPort}."); + Debug.Log($"UNITY-MCP: UnityMcpBridge started on port {currentUnityPort}."); Task.Run(ListenerLoop); EditorApplication.update += ProcessCommands; // Write initial heartbeat immediately @@ -226,7 +226,7 @@ public static void Stop() listener?.Stop(); listener = null; EditorApplication.update -= ProcessCommands; - Debug.Log("UnityMcpBridge stopped."); + Debug.Log("UNITY-MCP: UnityMcpBridge stopped."); } catch (Exception ex) { diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 408a63b5..f85aa6f4 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1990,37 +1990,91 @@ private bool IsPythonDetected() { try { - // Common absolute paths - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = - { - "/opt/homebrew/bin/python3", - "/usr/local/bin/python3", - "/usr/bin/python3", - "/opt/local/bin/python3", - Path.Combine(home, ".local", "bin", "python3"), - "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", - }; - foreach (string c in candidates) + // Windows-specific Python detection + if (Application.platform == RuntimePlatform.WindowsEditor) { - if (File.Exists(c)) return true; - } + // Common Windows Python installation paths + string[] windowsCandidates = + { + @"C:\Python313\python.exe", + @"C:\Python312\python.exe", + @"C:\Python311\python.exe", + @"C:\Python310\python.exe", + @"C:\Python39\python.exe", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python39\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python39\python.exe"), + }; + + foreach (string c in windowsCandidates) + { + if (File.Exists(c)) return true; + } - // Try 'which python3' - var psi = new ProcessStartInfo + // Try 'where python' command (Windows equivalent of 'which') + var psi = new ProcessStartInfo + { + FileName = "where", + Arguments = "python", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + string outp = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(2000); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) + { + string[] lines = outp.Split('\n'); + foreach (string line in lines) + { + string trimmed = line.Trim(); + if (File.Exists(trimmed)) return true; + } + } + } + else { - FileName = "/usr/bin/which", - Arguments = "python3", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = Process.Start(psi); - string outp = p.StandardOutput.ReadToEnd().Trim(); - p.WaitForExit(2000); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; + // macOS/Linux detection (existing code) + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/opt/homebrew/bin/python3", + "/usr/local/bin/python3", + "/usr/bin/python3", + "/opt/local/bin/python3", + Path.Combine(home, ".local", "bin", "python3"), + "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", + }; + foreach (string c in candidates) + { + if (File.Exists(c)) return true; + } + + // Try 'which python3' + var psi = new ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = "python3", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + string outp = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(2000); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; + } } catch { } return false; From 9a5d62128a11548a96ce913a2e950ac85ed90496 Mon Sep 17 00:00:00 2001 From: Jos van der Westhuizen Date: Sat, 9 Aug 2025 12:20:05 -0400 Subject: [PATCH 29/69] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b05d7d4f..ec23c4f9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Unity MCP ✨ -#### Proudly sponsored and maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp), the AI assistant for Unity. +#### Proudly sponsored and maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp), the AI assistant for Unity. [Read the backstory here.](https://www.coplay.dev/blog/coplay-and-open-source-unity-mcp-join-forces) [![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4) [![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive) From c0de38e1e7329bef9adffaa6efd65839a998153e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 9 Aug 2025 12:05:47 -0700 Subject: [PATCH 30/69] Merge upstream/main: CoplayDev rebrand with bridge stability improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This merge combines upstream's organizational rebrand and updates with our comprehensive bridge stability improvements: **From Upstream:** - CoplayDev organizational rebrand (README, LICENSE, documentation) - Updated logo and deployment scripts - Python version pinning (.python-version file) **From Our Branch (Preserved):** - Comprehensive bridge stability improvements (threading, heartbeat, retries) - Enhanced debugging and diagnostic features - Embedded server installation approach (more reliable than git-based) - Broader Python compatibility (>=3.10 vs >=3.12) - Advanced port management with per-project persistence - Auto-setup and connection reliability features - Robust error handling and recovery mechanisms **Key Technical Decisions:** - Used our comprehensive UnityMcpBridge.cs (625 lines vs 473) with all stability features - Maintained embedded server approach over upstream's git-based installer - Preserved broader Python compatibility (>=3.10) for better accessibility - Used our optimized connection settings and retry logic - Kept our user-centric server installation approach (on-demand vs automatic) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- LICENSE | 2 +- README-DEV.md | 2 +- README.md | 87 ++++++------- .../Editor/Helpers/PackageInstaller.cs | 8 +- UnityMcpBridge/Editor/Helpers/PortManager.cs | 21 ++-- .../Editor/Helpers/ServerInstaller.cs | 118 +++++++++++++----- .../Editor/Windows/UnityMcpEditorWindow.cs | 110 +++++++++++----- UnityMcpBridge/package.json | 26 ++++ UnityMcpServer/src/.python-version | 1 + deploy-dev.bat | 2 +- logo.png | Bin 0 -> 40503 bytes restore-dev.bat | 2 +- 12 files changed, 257 insertions(+), 122 deletions(-) create mode 100644 UnityMcpServer/src/.python-version create mode 100644 logo.png diff --git a/LICENSE b/LICENSE index ebeecf5b..e7f878d1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Justin P Barnett +Copyright (c) 2025 CoplayDev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README-DEV.md b/README-DEV.md index 954348ba..398bdab2 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -48,7 +48,7 @@ Restores original files from backup. Unity package cache is typically located at: ``` -X:\UnityProject\Library\PackageCache\com.justinpbarnett.unity-mcp@1.0.0 +X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 ``` To find it: diff --git a/README.md b/README.md index fd097f1d..ec23c4f9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # Unity MCP ✨ +#### Proudly sponsored and maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp), the AI assistant for Unity. [Read the backstory here.](https://www.coplay.dev/blog/coplay-and-open-source-unity-mcp-join-forces) + +[![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4) [![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive) [![python](https://img.shields.io/badge/Python-3.12-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org) [![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction) -![GitHub commit activity](https://img.shields.io/github/commit-activity/w/justinpbarnett/unity-mcp) -![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/justinpbarnett/unity-mcp) +![GitHub commit activity](https://img.shields.io/github/commit-activity/w/CoplayDev/unity-mcp) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/CoplayDev/unity-mcp) [![](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT) +[![](https://img.shields.io/badge/Sponsor-Coplay-red.svg 'Coplay')](https://www.coplay.dev/?ref=unity-mcp) **Create your Unity apps with LLMs!** @@ -13,14 +17,12 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte ## 💬 Join Our Community -### [Discord](https://discord.gg/vhTUxXaqYr) +### [Discord](https://discord.gg/y4p8KfzrN4) **Get help, share ideas, and collaborate with other Unity MCP developers!** - --- - ## Key Features 🚀 * **🗣️ Natural Language Control:** Instruct your LLM to perform Unity tasks. @@ -35,7 +37,7 @@ Unity MCP acts as a bridge, allowing AI assistants (like Claude, Cursor) to inte * `read_console`: Gets messages from or clears the console. * `manage_script`: Manages C# scripts (create, read, update, delete). - * `manage_editor`: Controls and queries the editor's state and settings. + * `manage_editor`: Controls and queries the editor\'s state and settings. * `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.). * `manage_asset`: Performs asset operations (import, create, modify, delete, etc.). * `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). @@ -62,7 +64,6 @@ Unity MCP connects your tools using two components: ### Prerequisites - * **Git CLI:** For cloning the server code. [Download Git](https://git-scm.com/downloads) * **Python:** Version 3.12 or newer. [Download Python](https://www.python.org/downloads/) * **Unity Hub & Editor:** Version 2020.3 LTS or newer. [Download Unity](https://unity.com/download) @@ -76,6 +77,7 @@ Unity MCP connects your tools using two components: * [Claude Code](https://github.com/anthropics/claude-code) * [Cursor](https://www.cursor.com/en/downloads) * [Visual Studio Code Copilot](https://code.visualstudio.com/docs/copilot/overview) + * [Windsurf](https://windsurf.com) * *(Others may work with manual config)* *
[Optional] Roslyn for Advanced Script Validation @@ -95,9 +97,8 @@ Unity MCP connects your tools using two components: 3. Ensure .NET compatibility settings are correct 4. Add `USE_ROSLYN` to Scripting Define Symbols 5. Restart Unity - - **Note:** Without Roslyn, script validation falls back to basic structural checks. Roslyn enables full C# compiler diagnostics with precise error reporting.
+ **Note:** Without Roslyn, script validation falls back to basic structural checks. Roslyn enables full C# compiler diagnostics with precise error reporting. ### Step 1: Install the Unity Package (Bridge) @@ -106,11 +107,13 @@ Unity MCP connects your tools using two components: 3. Click `+` -> `Add package from git URL...`. 4. Enter: ``` - https://github.com/justinpbarnett/unity-mcp.git?path=/UnityMcpBridge + https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge ``` 5. Click `Add`. 6. The MCP Server should automatically be installed onto your machine as a result of this process. +**Note:** If you installed the MCP Server before Coplay's maintenance, you will need to uninstall the old package before re-installing the new one. + ### Step 2: Configure Your MCP Client Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installed in Step 1. @@ -121,13 +124,13 @@ Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installe 1. In Unity, go to `Window > Unity MCP`. 2. Click `Auto Configure` on the IDE you uses. -3. Look for a green status indicator 🟢 and "Connected". *(This attempts to modify the MCP Client's config file automatically)*. +3. Look for a green status indicator 🟢 and "Connected". *(This attempts to modify the MCP Client\'s config file automatically)*. **Option B: Manual Configuration** If Auto-Configure fails or you use a different client: -1. **Find your MCP Client's configuration file.** (Check client documentation). +1. **Find your MCP Client\'s configuration file.** (Check client documentation). * *Claude Example (macOS):* `~/Library/Application Support/Claude/claude_desktop_config.json` * *Claude Example (Windows):* `%APPDATA%\Claude\claude_desktop_config.json` 2. **Edit the file** to add/update the `mcpServers` section, using the *exact* paths from Step 1. @@ -174,6 +177,7 @@ If Auto-Configure fails or you use a different client: } } ``` + (Replace YOUR_USERNAME if using ~/bin) **Linux:** @@ -197,18 +201,18 @@ If Auto-Configure fails or you use a different client: (Replace YOUR_USERNAME) - - **For Claude Code** -If you're using Claude Code, you can register the MCP server using these commands: +If you\'re using Claude Code, you can register the MCP server using these commands: **macOS:** + ```bash claude mcp add UnityMCP -- uv --directory /[PATH_TO]/UnityMCP/UnityMcpServer/src run server.py ``` **Windows:** + ```bash claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/Scripts/uv.exe" --directory "C:/Users/USERNAME/AppData/Local/Programs/UnityMCP/UnityMcpServer/src" run server.py ``` @@ -225,12 +229,13 @@ claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/S 3. **Interact!** Unity tools should now be available in your MCP Client. Example Prompt: `Create a 3D player controller`, `Create a yellow and bridge sun`, `Create a cool shader and apply it on a cube`. - + --- ## Future Dev Plans (Besides PR) 📝 ### 🔴 High Priority + - [ ] **Asset Generation Improvements** - Enhanced server request handling and asset pipeline optimization - [ ] **Code Generation Enhancements** - Improved generated code quality and error handling - [ ] **Robust Error Handling** - Comprehensive error messages, recovery mechanisms, and graceful degradation @@ -238,10 +243,12 @@ claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/S - [ ] **Documentation Expansion** - Complete tutorials for custom tool creation and API reference ### 🟡 Medium Priority + - [ ] **Custom Tool Creation GUI** - Visual interface for users to create and configure their own MCP tools - [ ] **Advanced Logging System** - Logging with filtering, export, and debugging capabilities ### 🟢 Low Priority + - [ ] **Mobile Platform Support** - Extended toolset for mobile development workflows and platform-specific features - [ ] **Easier Tool Setup** - [ ] **Plugin Marketplace** - Community-driven tool sharing and distribution platform @@ -254,6 +261,7 @@ claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/S ### 🔬 Research & Exploration + - [ ] **AI-Powered Asset Generation** - Integration with AI tools for automatic 3D models, textures, and animations - [ ] **Real-time Collaboration** - Live editing sessions between multiple developers *(Currently in progress)* - [ ] **Analytics Dashboard** - Usage analytics, project insights, and performance metrics @@ -266,7 +274,7 @@ claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/S ### Development Tools -If you're contributing to Unity MCP or want to test core changes, we have development tools to streamline your workflow: +If you\'re contributing to Unity MCP or want to test core changes, we have development tools to streamline your workflow: - **Development Deployment Scripts**: Quickly deploy and test your changes to Unity MCP Bridge and Python Server - **Automatic Backup System**: Safe testing with easy rollback capabilities @@ -289,8 +297,7 @@ Help make Unity MCP better! 5. **Push** your branch. -6. **Open a Pull Request** against the master branch. - +6. **Open a Pull Request** against the main branch. --- @@ -300,53 +307,37 @@ Help make Unity MCP better! Click to view common issues and fixes... - **Unity Bridge Not Running/Connecting:** - - Ensure Unity Editor is open. - - Check the status window: Window > Unity MCP. - - Restart Unity. - - **MCP Client Not Connecting / Server Not Starting:** - - - **Verify Server Path:** Double-check the --directory path in your MCP Client's JSON config. It must exactly match the location where you cloned the UnityMCP repository in Installation Step 1 (e.g., .../Programs/UnityMCP/UnityMcpServer/src). - - - **Verify uv:** Make sure uv is installed and working (pip show uv). - + - **Verify Server Path:** Double-check the --directory path in your MCP Client\'s JSON config. It must exactly match the location where you cloned the UnityMCP repository in Installation Step 1 (e.g., .../Programs/UnityMCP/UnityMcpServer/src). + - **Verify uv:** Make sure `uv` is installed and working (pip show uv). - **Run Manually:** Try running the server directly from the terminal to see errors: `# Navigate to the src directory first! cd /path/to/your/UnityMCP/UnityMcpServer/src uv run server.py` - - **Permissions (macOS/Linux):** If you installed the server in a system location like /usr/local/bin, ensure the user running the MCP client has permission to execute uv and access files there. Installing in ~/bin might be easier. - - **Auto-Configure Failed:** - - - Use the Manual Configuration steps. Auto-configure might lack permissions to write to the MCP client's config file. - + - Use the Manual Configuration steps. Auto-configure might lack permissions to write to the MCP client\'s config file. -Still stuck? [Open an Issue](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgithub.com%2Fjustinpbarnett%2Funity-mcp%2Fissues) or [Join the Discord](https://discord.gg/vhTUxXaqYr)! - ---- - -## Contact 👋 - -- **justinpbarnett:** [X/Twitter](https://www.google.com/url?sa=E&q=https%3A%2F%2Fx.com%2Fjustinpbarnett) -- **scriptwonder**: [Email](mailto:swu85@ur.rochester.edu), [LinkedIn](https://www.linkedin.com/in/shutong-wu-214043172/) - +Still stuck? [Open an Issue](https://github.com/CoplayDev/unity-mcp/issues) or [Join the Discord](https://discord.gg/y4p8KfzrN4)! --- ## License 📜 -MIT License. See [LICENSE](https://www.google.com/url?sa=E&q=https%3A%2F%2Fgithub.com%2Fjustinpbarnett%2Funity-mcp%2Fblob%2Fmaster%2FLICENSE) file. +MIT License. See [LICENSE](LICENSE) file. --- -## Acknowledgments 🙏 - -Thanks to the contributors and the Unity team. +## Star History +[![Star History Chart](https://api.star-history.com/svg?repos=CoplayDev/unity-mcp&type=Date)](https://www.star-history.com/#CoplayDev/unity-mcp&Date) -## Star History +## Sponsor -[![Star History Chart](https://api.star-history.com/svg?repos=justinpbarnett/unity-mcp&type=Date)](https://www.star-history.com/#justinpbarnett/unity-mcp&Date) +

+ + Coplay Logo + +

diff --git a/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs index 75cdb3b9..ae420a26 100644 --- a/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs @@ -25,18 +25,18 @@ private static void InstallServerOnFirstLoad() { try { - Debug.Log("Unity MCP: Installing Python server..."); + Debug.Log("UNITY-MCP: Installing Python server..."); ServerInstaller.EnsureServerInstalled(); // Mark as installed EditorPrefs.SetBool(InstallationFlagKey, true); - Debug.Log("Unity MCP: Python server installation completed successfully."); + Debug.Log("UNITY-MCP: Python server installation completed successfully."); } catch (System.Exception ex) { - Debug.LogError($"Unity MCP: Failed to install Python server: {ex.Message}"); - Debug.LogWarning("Unity MCP: You may need to manually install the Python server. Check the Unity MCP Editor Window for instructions."); + Debug.LogError($"UNITY-MCP: Failed to install Python server: {ex.Message}"); + Debug.LogWarning("UNITY-MCP: You may need to manually install the Python server. Check the Unity MCP Editor Window for instructions."); } } } diff --git a/UnityMcpBridge/Editor/Helpers/PortManager.cs b/UnityMcpBridge/Editor/Helpers/PortManager.cs index 9caeccca..376f9163 100644 --- a/UnityMcpBridge/Editor/Helpers/PortManager.cs +++ b/UnityMcpBridge/Editor/Helpers/PortManager.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using UnityEditor; using System.Net; using System.Net.Sockets; using System.Security.Cryptography; @@ -15,6 +16,12 @@ namespace UnityMcpBridge.Editor.Helpers /// public static class PortManager { + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("UnityMCP.DebugLogs", false); } + catch { return false; } + } + private const int DefaultPort = 6400; private const int MaxPortAttempts = 100; private const string RegistryFileName = "unity-mcp-port.json"; @@ -41,7 +48,7 @@ public static int GetPortWithFallback() string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && IsPortAvailable(storedConfig.unity_port)) { - Debug.Log($"Using stored port {storedConfig.unity_port} for current project"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Using stored port {storedConfig.unity_port} for current project"); return storedConfig.unity_port; } @@ -50,7 +57,7 @@ public static int GetPortWithFallback() { if (WaitForPortRelease(storedConfig.unity_port, 1500)) { - Debug.Log($"Stored port {storedConfig.unity_port} became available after short wait"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Stored port {storedConfig.unity_port} became available after short wait"); return storedConfig.unity_port; } // Prefer sticking to the same port; let the caller handle bind retries/fallbacks @@ -71,7 +78,7 @@ public static int DiscoverNewPort() { int newPort = FindAvailablePort(); SavePort(newPort); - Debug.Log($"Discovered and saved new port: {newPort}"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Discovered and saved new port: {newPort}"); return newPort; } @@ -84,18 +91,18 @@ private static int FindAvailablePort() // Always try default port first if (IsPortAvailable(DefaultPort)) { - Debug.Log($"Using default port {DefaultPort}"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Using default port {DefaultPort}"); return DefaultPort; } - Debug.Log($"Default port {DefaultPort} is in use, searching for alternative..."); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Default port {DefaultPort} is in use, searching for alternative..."); // Search for alternatives for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) { if (IsPortAvailable(port)) { - Debug.Log($"Found available port {port}"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Found available port {port}"); return port; } } @@ -204,7 +211,7 @@ private static void SavePort(int port) string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); File.WriteAllText(legacy, json); - Debug.Log($"Saved port {port} to storage"); + if (IsDebugEnabled()) Debug.Log($"UNITY-MCP: Saved port {port} to storage"); } catch (Exception ex) { diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index ed92786a..03b753f2 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -281,7 +281,7 @@ public static bool RepairPythonEnvironment() return false; } - Debug.Log("Unity MCP: Python environment repaired successfully."); + Debug.Log("UNITY-MCP: Python environment repaired successfully."); return true; } catch (Exception ex) @@ -305,47 +305,100 @@ private static string FindUvPath() catch { } string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = + + // Platform-specific candidate lists + string[] candidates; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + candidates = new[] + { + // Common per-user installs + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python313\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python312\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python311\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python310\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python313\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python312\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python311\Scripts\uv.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python310\Scripts\uv.exe"), + // Program Files style installs (if a native installer was used) + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty, @"uv\uv.exe"), + // Try simple name resolution later via PATH + "uv.exe", + "uv" + }; + } + else { - "/opt/homebrew/bin/uv", - "/usr/local/bin/uv", - "/usr/bin/uv", - "/opt/local/bin/uv", - Path.Combine(home, ".local", "bin", "uv"), - "/opt/homebrew/opt/uv/bin/uv", - // Framework Python installs - "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv", - // Fallback to PATH resolution by name - "uv" - }; + candidates = new[] + { + "/opt/homebrew/bin/uv", + "/usr/local/bin/uv", + "/usr/bin/uv", + "/opt/local/bin/uv", + Path.Combine(home, ".local", "bin", "uv"), + "/opt/homebrew/opt/uv/bin/uv", + // Framework Python installs + "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv", + // Fallback to PATH resolution by name + "uv" + }; + } + foreach (string c in candidates) { try { - if (ValidateUvBinary(c)) return c; + if (File.Exists(c) && ValidateUvBinary(c)) return c; } catch { /* ignore */ } } - // Try which uv (explicit path) + // Use platform-appropriate which/where to resolve from PATH try { - var whichPsi = new System.Diagnostics.ProcessStartInfo + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - FileName = "/usr/bin/which", - Arguments = "uv", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var wp = System.Diagnostics.Process.Start(whichPsi); - string output = wp.StandardOutput.ReadToEnd().Trim(); - wp.WaitForExit(3000); - if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + var wherePsi = new System.Diagnostics.ProcessStartInfo + { + FileName = "where", + Arguments = "uv.exe", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var wp = System.Diagnostics.Process.Start(wherePsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(3000); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + string path = line.Trim(); + if (File.Exists(path) && ValidateUvBinary(path)) return path; + } + } + } + else { - if (ValidateUvBinary(output)) return output; + var whichPsi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = "uv", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var wp = System.Diagnostics.Process.Start(whichPsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(3000); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + { + if (ValidateUvBinary(output)) return output; + } } } catch { } @@ -359,8 +412,11 @@ private static string FindUvPath() { try { - string candidate = Path.Combine(part, "uv"); - if (File.Exists(candidate) && ValidateUvBinary(candidate)) return candidate; + // Check both uv and uv.exe + string candidateUv = Path.Combine(part, "uv"); + string candidateUvExe = Path.Combine(part, "uv.exe"); + if (File.Exists(candidateUv) && ValidateUvBinary(candidateUv)) return candidateUv; + if (File.Exists(candidateUvExe) && ValidateUvBinary(candidateUvExe)) return candidateUvExe; } catch { } } diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 408a63b5..f85aa6f4 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1990,37 +1990,91 @@ private bool IsPythonDetected() { try { - // Common absolute paths - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = - { - "/opt/homebrew/bin/python3", - "/usr/local/bin/python3", - "/usr/bin/python3", - "/opt/local/bin/python3", - Path.Combine(home, ".local", "bin", "python3"), - "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", - }; - foreach (string c in candidates) + // Windows-specific Python detection + if (Application.platform == RuntimePlatform.WindowsEditor) { - if (File.Exists(c)) return true; - } + // Common Windows Python installation paths + string[] windowsCandidates = + { + @"C:\Python313\python.exe", + @"C:\Python312\python.exe", + @"C:\Python311\python.exe", + @"C:\Python310\python.exe", + @"C:\Python39\python.exe", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python39\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python39\python.exe"), + }; + + foreach (string c in windowsCandidates) + { + if (File.Exists(c)) return true; + } - // Try 'which python3' - var psi = new ProcessStartInfo + // Try 'where python' command (Windows equivalent of 'which') + var psi = new ProcessStartInfo + { + FileName = "where", + Arguments = "python", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + string outp = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(2000); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) + { + string[] lines = outp.Split('\n'); + foreach (string line in lines) + { + string trimmed = line.Trim(); + if (File.Exists(trimmed)) return true; + } + } + } + else { - FileName = "/usr/bin/which", - Arguments = "python3", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = Process.Start(psi); - string outp = p.StandardOutput.ReadToEnd().Trim(); - p.WaitForExit(2000); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; + // macOS/Linux detection (existing code) + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/opt/homebrew/bin/python3", + "/usr/local/bin/python3", + "/usr/bin/python3", + "/opt/local/bin/python3", + Path.Combine(home, ".local", "bin", "python3"), + "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", + }; + foreach (string c in candidates) + { + if (File.Exists(c)) return true; + } + + // Try 'which python3' + var psi = new ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = "python3", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + string outp = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(2000); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; + } } catch { } return false; diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index d1ee0081..84717103 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,4 +1,5 @@ { +<<<<<<< HEAD "name": "com.justinpbarnett.unity-mcp", "version": "2.0.0", "displayName": "Unity MCP Bridge", @@ -6,5 +7,30 @@ "unity": "2020.3", "dependencies": { "com.unity.nuget.newtonsoft-json": "3.0.2" +======= + "name": "com.coplaydev.unity-mcp", + "version": "1.0.0", + "displayName": "Unity MCP Bridge", + "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", + "unity": "2020.3", + "documentationUrl": "https://github.com/CoplayDev/unity-mcp", + "licensesUrl": "https://github.com/CoplayDev/unity-mcp/blob/main/LICENSE", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.0.2" + }, + "keywords": [ + "unity", + "ai", + "llm", + "mcp", + "model-context-protocol", + "mcp-server", + "mcp-client" + ], + "author": { + "name": "CoplayDev", + "email": "support@coplay.dev", + "url": "https://coplay.dev" +>>>>>>> upstream/main } } diff --git a/UnityMcpServer/src/.python-version b/UnityMcpServer/src/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/UnityMcpServer/src/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/deploy-dev.bat b/deploy-dev.bat index 4cc61de9..6a83fcf0 100644 --- a/deploy-dev.bat +++ b/deploy-dev.bat @@ -19,7 +19,7 @@ echo. :: Package cache location echo Unity Package Cache Location: -echo Example: X:\UnityProject\Library\PackageCache\com.justinpbarnett.unity-mcp@1.0.0 +echo Example: X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " if "%PACKAGE_CACHE_PATH%"=="" ( diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9472a36068e9c721e58811c30f65dfd2a9fc6693 GIT binary patch literal 40503 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 IHt|XRFY90JK>z>% literal 0 HcmV?d00001 diff --git a/restore-dev.bat b/restore-dev.bat index a8e327ad..69d9312b 100644 --- a/restore-dev.bat +++ b/restore-dev.bat @@ -16,7 +16,7 @@ echo. :: Package cache location echo Unity Package Cache Location: -echo Example: X:\UnityProject\Library\PackageCache\com.justinpbarnett.unity-mcp@1.0.0 +echo Example: X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " if "%PACKAGE_CACHE_PATH%"=="" ( From 9da9739751b474a4eb3534cf4be0b4e4a68c5072 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 9 Aug 2025 12:49:32 -0700 Subject: [PATCH 31/69] Package Python server under UnityMcpServer~; remove redundant .meta files; delete old root UnityMcpServer; update editor lookup for tilde path; adjust deploy/restore scripts; remove orphan meta --- .../Editor/Helpers/ServerInstaller.cs | 10 +- .../Editor/Windows/UnityMcpEditorWindow.cs | 9 +- UnityMcpBridge/UnityMcpServer.meta | 8 - UnityMcpBridge/UnityMcpServer/src.meta | 8 - UnityMcpBridge/UnityMcpServer/src/Dockerfile | 27 -- .../UnityMcpServer/src/Dockerfile.meta | 7 - UnityMcpBridge/UnityMcpServer/src/__init__.py | 3 - .../UnityMcpServer/src/__init__.py.meta | 7 - UnityMcpBridge/UnityMcpServer/src/config.py | 30 -- .../UnityMcpServer/src/config.py.meta | 7 - .../UnityMcpServer/src/port_discovery.py | 155 ------- .../UnityMcpServer/src/port_discovery.py.meta | 7 - .../UnityMcpServer/src/pyproject.toml | 15 - .../UnityMcpServer/src/pyproject.toml.meta | 7 - UnityMcpBridge/UnityMcpServer/src/server.py | 73 ---- .../UnityMcpServer/src/server.py.meta | 7 - UnityMcpBridge/UnityMcpServer/src/tools.meta | 8 - .../UnityMcpServer/src/tools/__init__.py | 21 - .../UnityMcpServer/src/tools/__init__.py.meta | 7 - .../src/tools/execute_menu_item.py | 51 --- .../src/tools/execute_menu_item.py.meta | 7 - .../UnityMcpServer/src/tools/manage_asset.py | 83 ---- .../src/tools/manage_asset.py.meta | 7 - .../UnityMcpServer/src/tools/manage_editor.py | 53 --- .../src/tools/manage_editor.py.meta | 7 - .../src/tools/manage_gameobject.py | 138 ------ .../src/tools/manage_gameobject.py.meta | 7 - .../UnityMcpServer/src/tools/manage_scene.py | 47 -- .../src/tools/manage_scene.py.meta | 7 - .../UnityMcpServer/src/tools/manage_script.py | 74 ---- .../src/tools/manage_script.py.meta | 7 - .../UnityMcpServer/src/tools/manage_shader.py | 67 --- .../src/tools/manage_shader.py.meta | 7 - .../UnityMcpServer/src/tools/read_console.py | 70 --- .../src/tools/read_console.py.meta | 7 - .../UnityMcpServer/src/unity_connection.py | 239 ----------- .../src/unity_connection.py.meta | 7 - UnityMcpBridge/UnityMcpServer/src/uv.lock | 349 --------------- .../UnityMcpServer/src/uv.lock.meta | 7 - UnityMcpBridge/package.json | 10 - UnityMcpServer/src/.python-version | 1 - UnityMcpServer/src/Dockerfile | 27 -- UnityMcpServer/src/__init__.py | 3 - UnityMcpServer/src/config.py | 30 -- UnityMcpServer/src/port_discovery.py | 155 ------- UnityMcpServer/src/pyproject.toml | 15 - UnityMcpServer/src/server.py | 73 ---- UnityMcpServer/src/tools/__init__.py | 21 - UnityMcpServer/src/tools/execute_menu_item.py | 51 --- UnityMcpServer/src/tools/manage_asset.py | 83 ---- UnityMcpServer/src/tools/manage_editor.py | 53 --- UnityMcpServer/src/tools/manage_gameobject.py | 138 ------ UnityMcpServer/src/tools/manage_scene.py | 47 -- UnityMcpServer/src/tools/manage_script.py | 74 ---- UnityMcpServer/src/tools/manage_shader.py | 67 --- UnityMcpServer/src/tools/read_console.py | 70 --- UnityMcpServer/src/unity_connection.py | 239 ----------- UnityMcpServer/src/uv.lock | 400 ------------------ deploy-dev.bat | 2 +- restore-dev.bat | 3 + 60 files changed, 21 insertions(+), 3198 deletions(-) delete mode 100644 UnityMcpBridge/UnityMcpServer.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/Dockerfile delete mode 100644 UnityMcpBridge/UnityMcpServer/src/Dockerfile.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/__init__.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/__init__.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/config.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/config.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/port_discovery.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/port_discovery.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/pyproject.toml delete mode 100644 UnityMcpBridge/UnityMcpServer/src/pyproject.toml.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/server.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/server.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/__init__.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/__init__.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/read_console.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/tools/read_console.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/unity_connection.py delete mode 100644 UnityMcpBridge/UnityMcpServer/src/unity_connection.py.meta delete mode 100644 UnityMcpBridge/UnityMcpServer/src/uv.lock delete mode 100644 UnityMcpBridge/UnityMcpServer/src/uv.lock.meta delete mode 100644 UnityMcpServer/src/.python-version delete mode 100644 UnityMcpServer/src/Dockerfile delete mode 100644 UnityMcpServer/src/__init__.py delete mode 100644 UnityMcpServer/src/config.py delete mode 100644 UnityMcpServer/src/port_discovery.py delete mode 100644 UnityMcpServer/src/pyproject.toml delete mode 100644 UnityMcpServer/src/server.py delete mode 100644 UnityMcpServer/src/tools/__init__.py delete mode 100644 UnityMcpServer/src/tools/execute_menu_item.py delete mode 100644 UnityMcpServer/src/tools/manage_asset.py delete mode 100644 UnityMcpServer/src/tools/manage_editor.py delete mode 100644 UnityMcpServer/src/tools/manage_gameobject.py delete mode 100644 UnityMcpServer/src/tools/manage_scene.py delete mode 100644 UnityMcpServer/src/tools/manage_script.py delete mode 100644 UnityMcpServer/src/tools/manage_shader.py delete mode 100644 UnityMcpServer/src/tools/read_console.py delete mode 100644 UnityMcpServer/src/unity_connection.py delete mode 100644 UnityMcpServer/src/uv.lock diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 03b753f2..a5f0999a 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -147,7 +147,15 @@ private static bool TryGetEmbeddedServerSource(out string srcPath) { string packagePath = pkg.resolvedPath; // e.g., Library/PackageCache/... or local path - // Preferred: UnityMcpServer embedded alongside Editor/Runtime within the package + // Preferred: UnityMcpServer~ embedded alongside Editor/Runtime within the package (ignored by Unity import) + string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); + if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) + { + srcPath = embeddedTilde; + return true; + } + + // Fallback: legacy non-tilde folder name inside the package string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) { diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index f85aa6f4..5dde570c 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1000,7 +1000,14 @@ private string FindPackagePythonDirectory() { string packagePath = package.resolvedPath; - // Check for local package structure (UnityMcpServer/src) + // Preferred: check for tilde folder inside package + string packagedTildeDir = Path.Combine(packagePath, "UnityMcpServer~", "src"); + if (Directory.Exists(packagedTildeDir) && File.Exists(Path.Combine(packagedTildeDir, "server.py"))) + { + return packagedTildeDir; + } + + // Fallback: legacy local package structure (UnityMcpServer/src) string localPythonDir = Path.Combine(Path.GetDirectoryName(packagePath), "UnityMcpServer", "src"); if (Directory.Exists(localPythonDir) && File.Exists(Path.Combine(localPythonDir, "server.py"))) { diff --git a/UnityMcpBridge/UnityMcpServer.meta b/UnityMcpBridge/UnityMcpServer.meta deleted file mode 100644 index a391da27..00000000 --- a/UnityMcpBridge/UnityMcpServer.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 1f4e9c3e4a2b4e12a1b2c3d4e5f60789 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src.meta b/UnityMcpBridge/UnityMcpServer/src.meta deleted file mode 100644 index 8495be14..00000000 --- a/UnityMcpBridge/UnityMcpServer/src.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 661ad50b20643440fbed55a237c6db95 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/Dockerfile b/UnityMcpBridge/UnityMcpServer/src/Dockerfile deleted file mode 100644 index 3f884f37..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -FROM python:3.12-slim - -# Install required system dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ - && rm -rf /var/lib/apt/lists/* - -# Set working directory -WORKDIR /app - -# Install uv package manager -RUN pip install uv - -# Copy required files -COPY config.py /app/ -COPY server.py /app/ -COPY unity_connection.py /app/ -COPY pyproject.toml /app/ -COPY __init__.py /app/ -COPY tools/ /app/tools/ - -# Install dependencies using uv -RUN uv pip install --system -e . - - -# Command to run the server -CMD ["uv", "run", "server.py"] \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/Dockerfile.meta b/UnityMcpBridge/UnityMcpServer/src/Dockerfile.meta deleted file mode 100644 index 8b821f0e..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/Dockerfile.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 6fa88615288954da09edbaa8118d833d -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/__init__.py b/UnityMcpBridge/UnityMcpServer/src/__init__.py deleted file mode 100644 index 62e5cd1f..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Unity MCP Server package. -""" \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/__init__.py.meta b/UnityMcpBridge/UnityMcpServer/src/__init__.py.meta deleted file mode 100644 index 5cad7ab5..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/__init__.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 59ba898760fd24167997d22d2705b8a4 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/config.py b/UnityMcpBridge/UnityMcpServer/src/config.py deleted file mode 100644 index 485b845d..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/config.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Configuration settings for the Unity MCP Server. -This file contains all configurable parameters for the server. -""" - -from dataclasses import dataclass - -@dataclass -class ServerConfig: - """Main configuration class for the MCP server.""" - - # Network settings - unity_host: str = "localhost" - unity_port: int = 6400 - mcp_port: int = 6500 - - # Connection settings - connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts - buffer_size: int = 16 * 1024 * 1024 # 16MB buffer - - # Logging settings - log_level: str = "INFO" - log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - - # Server settings - max_retries: int = 8 - retry_delay: float = 0.5 - -# Create a global config instance -config = ServerConfig() \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/config.py.meta b/UnityMcpBridge/UnityMcpServer/src/config.py.meta deleted file mode 100644 index 75f04e78..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/config.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 5516f911d79504c71976757e67ca228b -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/port_discovery.py b/UnityMcpBridge/UnityMcpServer/src/port_discovery.py deleted file mode 100644 index 98855333..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/port_discovery.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -Port discovery utility for Unity MCP Server. - -What changed and why: -- Unity now writes a per-project port file named like - `~/.unity-mcp/unity-mcp-port-.json` to avoid projects overwriting - each other's saved port. The legacy file `unity-mcp-port.json` may still - exist. -- This module now scans for both patterns, prefers the most recently - modified file, and verifies that the port is actually a Unity MCP listener - (quick socket connect + ping) before choosing it. -""" - -import json -import os -import logging -from pathlib import Path -from typing import Optional, List -import glob -import socket - -logger = logging.getLogger("unity-mcp-server") - -class PortDiscovery: - """Handles port discovery from Unity Bridge registry""" - REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file - DEFAULT_PORT = 6400 - CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery - - @staticmethod - def get_registry_path() -> Path: - """Get the path to the port registry file""" - return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE - - @staticmethod - def get_registry_dir() -> Path: - return Path.home() / ".unity-mcp" - - @staticmethod - def list_candidate_files() -> List[Path]: - """Return candidate registry files, newest first. - Includes hashed per-project files and the legacy file (if present). - """ - base = PortDiscovery.get_registry_dir() - hashed = sorted( - (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - legacy = PortDiscovery.get_registry_path() - if legacy.exists(): - # Put legacy at the end so hashed, per-project files win - hashed.append(legacy) - return hashed - - @staticmethod - def _try_probe_unity_mcp(port: int) -> bool: - """Quickly check if a Unity MCP listener is on this port. - Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. - """ - try: - with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: - s.settimeout(PortDiscovery.CONNECT_TIMEOUT) - try: - s.sendall(b"ping") - data = s.recv(512) - # Minimal validation: look for a success pong response - if data and b'"message":"pong"' in data: - return True - except Exception: - return False - except Exception: - return False - return False - - @staticmethod - def _read_latest_status() -> Optional[dict]: - try: - base = PortDiscovery.get_registry_dir() - status_files = sorted( - (Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - if not status_files: - return None - with status_files[0].open('r') as f: - return json.load(f) - except Exception: - return None - - @staticmethod - def discover_unity_port() -> int: - """ - Discover Unity port by scanning per-project and legacy registry files. - Prefer the newest file whose port responds; fall back to first parsed - value; finally default to 6400. - - Returns: - Port number to connect to - """ - # Prefer the latest heartbeat status if it points to a responsive port - status = PortDiscovery._read_latest_status() - if status: - port = status.get('unity_port') - if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): - logger.info(f"Using Unity port from status: {port}") - return port - - candidates = PortDiscovery.list_candidate_files() - - first_seen_port: Optional[int] = None - - for path in candidates: - try: - with open(path, 'r') as f: - cfg = json.load(f) - unity_port = cfg.get('unity_port') - if isinstance(unity_port, int): - if first_seen_port is None: - first_seen_port = unity_port - if PortDiscovery._try_probe_unity_mcp(unity_port): - logger.info(f"Using Unity port from {path.name}: {unity_port}") - return unity_port - except Exception as e: - logger.warning(f"Could not read port registry {path}: {e}") - - if first_seen_port is not None: - logger.info(f"No responsive port found; using first seen value {first_seen_port}") - return first_seen_port - - # Fallback to default port - logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") - return PortDiscovery.DEFAULT_PORT - - @staticmethod - def get_port_config() -> Optional[dict]: - """ - Get the most relevant port configuration from registry. - Returns the most recent hashed file's config if present, - otherwise the legacy file's config. Returns None if nothing exists. - - Returns: - Port configuration dict or None if not found - """ - candidates = PortDiscovery.list_candidate_files() - if not candidates: - return None - for path in candidates: - try: - with open(path, 'r') as f: - return json.load(f) - except Exception as e: - logger.warning(f"Could not read port configuration {path}: {e}") - return None \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/port_discovery.py.meta b/UnityMcpBridge/UnityMcpServer/src/port_discovery.py.meta deleted file mode 100644 index e792556d..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/port_discovery.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 8d315755217ea4c36b221ac0461032ab -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/pyproject.toml b/UnityMcpBridge/UnityMcpServer/src/pyproject.toml deleted file mode 100644 index 2c05fb83..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/pyproject.toml +++ /dev/null @@ -1,15 +0,0 @@ -[project] -name = "UnityMcpServer" -version = "2.0.0" -description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." -readme = "README.md" -requires-python = ">=3.10" -dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"] - -[build-system] -requires = ["setuptools>=64.0.0", "wheel"] -build-backend = "setuptools.build_meta" - -[tool.setuptools] -py-modules = ["config", "server", "unity_connection"] -packages = ["tools"] diff --git a/UnityMcpBridge/UnityMcpServer/src/pyproject.toml.meta b/UnityMcpBridge/UnityMcpServer/src/pyproject.toml.meta deleted file mode 100644 index 86408e10..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/pyproject.toml.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 66fbd8ab4fd094540ba73299b6a2424a -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/server.py b/UnityMcpBridge/UnityMcpServer/src/server.py deleted file mode 100644 index 55360b57..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/server.py +++ /dev/null @@ -1,73 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context, Image -import logging -from dataclasses import dataclass -from contextlib import asynccontextmanager -from typing import AsyncIterator, Dict, Any, List -from config import config -from tools import register_all_tools -from unity_connection import get_unity_connection, UnityConnection - -# Configure logging using settings from config -logging.basicConfig( - level=getattr(logging, config.log_level), - format=config.log_format -) -logger = logging.getLogger("unity-mcp-server") - -# Global connection state -_unity_connection: UnityConnection = None - -@asynccontextmanager -async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: - """Handle server startup and shutdown.""" - global _unity_connection - logger.info("Unity MCP Server starting up") - try: - _unity_connection = get_unity_connection() - logger.info("Connected to Unity on startup") - except Exception as e: - logger.warning(f"Could not connect to Unity on startup: {str(e)}") - _unity_connection = None - try: - # Yield the connection object so it can be attached to the context - # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) - yield {"bridge": _unity_connection} - finally: - if _unity_connection: - _unity_connection.disconnect() - _unity_connection = None - logger.info("Unity MCP Server shut down") - -# Initialize MCP server -mcp = FastMCP( - "unity-mcp-server", - description="Unity Editor integration via Model Context Protocol", - lifespan=server_lifespan -) - -# Register all tools -register_all_tools(mcp) - -# Asset Creation Strategy - -@mcp.prompt() -def asset_creation_strategy() -> str: - """Guide for discovering and using Unity MCP tools effectively.""" - return ( - "Available Unity MCP Server Tools:\\n\\n" - "- `manage_editor`: Controls editor state and queries info.\\n" - "- `execute_menu_item`: Executes Unity Editor menu items by path.\\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" - "- `manage_script`: Manages C# script files.\\n" - "- `manage_asset`: Manages prefabs and assets.\\n" - "- `manage_shader`: Manages shaders.\\n\\n" - "Tips:\\n" - "- Create prefabs for reusable GameObjects.\\n" - "- Always include a camera and main light in your scenes.\\n" - ) - -# Run the server -if __name__ == "__main__": - mcp.run(transport='stdio') diff --git a/UnityMcpBridge/UnityMcpServer/src/server.py.meta b/UnityMcpBridge/UnityMcpServer/src/server.py.meta deleted file mode 100644 index 4e1c95b8..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/server.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 8ef892978afc74491b6cf65f40514e74 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools.meta b/UnityMcpBridge/UnityMcpServer/src/tools.meta deleted file mode 100644 index 0b8416a1..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 205ac300b2209414f8b246354e853777 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py deleted file mode 100644 index 4d8d63cf..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from .manage_script import register_manage_script_tools -from .manage_scene import register_manage_scene_tools -from .manage_editor import register_manage_editor_tools -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 - -def register_all_tools(mcp): - """Register all refactored tools with the MCP server.""" - print("Registering Unity MCP Server refactored tools...") - register_manage_script_tools(mcp) - register_manage_scene_tools(mcp) - register_manage_editor_tools(mcp) - register_manage_gameobject_tools(mcp) - register_manage_asset_tools(mcp) - register_manage_shader_tools(mcp) - register_read_console_tools(mcp) - register_execute_menu_item_tools(mcp) - print("Unity MCP Server tool registration complete.") diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py.meta deleted file mode 100644 index 56b02253..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/__init__.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 85da958dba57e47b9a2fa32a8abd61ef -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py b/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py deleted file mode 100644 index a4ebc672..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py +++ /dev/null @@ -1,51 +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 # Import unity_connection module - -def register_execute_menu_item_tools(mcp: FastMCP): - """Registers the execute_menu_item tool with the MCP server.""" - - @mcp.tool() - async def execute_menu_item( - ctx: Context, - 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 - - # Get Unity connection and send the command - # We use the unity_connection module to communicate with Unity - unity_conn = get_unity_connection() - - # Send command to the ExecuteMenuItem C# handler - # The command type should match what the Unity side expects - return unity_conn.send_command("execute_menu_item", params_dict) \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py.meta deleted file mode 100644 index 16b394f5..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/execute_menu_item.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 50ba0cffcdba2452a89ac372d67b4787 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py deleted file mode 100644 index dada66b3..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Defines the manage_asset tool for interacting with Unity assets. -""" -import asyncio # Added: Import asyncio for running sync code in async -from typing import Dict, Any -from mcp.server.fastmcp import FastMCP, Context -# from ..unity_connection import get_unity_connection # Original line that caused error -from unity_connection import get_unity_connection # Use absolute import relative to Python dir - -def register_manage_asset_tools(mcp: FastMCP): - """Registers the manage_asset tool with the MCP server.""" - - @mcp.tool() - async def manage_asset( - ctx: Context, - action: str, - path: str, - asset_type: str = None, - properties: Dict[str, Any] = None, - destination: str = None, - generate_preview: bool = False, - search_pattern: str = None, - filter_type: str = None, - filter_date_after: str = None, - page_size: int = None, - page_number: int = None - ) -> Dict[str, Any]: - """Performs asset operations (import, create, modify, delete, etc.) in Unity. - - Args: - ctx: The MCP context. - action: Operation to perform (e.g., 'import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components'). - path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope. - asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'. - properties: Dictionary of properties for 'create'/'modify'. - example properties for Material: {"color": [1, 0, 0, 1], "shader": "Standard"}. - example properties for Texture: {"width": 1024, "height": 1024, "format": "RGBA32"}. - example properties for PhysicsMaterial: {"bounciness": 1.0, "staticFriction": 0.5, "dynamicFriction": 0.5}. - destination: Target path for 'duplicate'/'move'. - search_pattern: Search pattern (e.g., '*.prefab'). - filter_*: Filters for search (type, date). - page_*: Pagination for search. - - Returns: - A dictionary with operation results ('success', 'data', 'error'). - """ - # Ensure properties is a dict if None - if properties is None: - properties = {} - - # Prepare parameters for the C# handler - params_dict = { - "action": action.lower(), - "path": path, - "assetType": asset_type, - "properties": properties, - "destination": destination, - "generatePreview": generate_preview, - "searchPattern": search_pattern, - "filterType": filter_type, - "filterDateAfter": filter_date_after, - "pageSize": page_size, - "pageNumber": page_number - } - - # Remove None values to avoid sending unnecessary nulls - 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() - # Get the Unity connection instance - connection = get_unity_connection() - - # Run the synchronous send_command in the default executor (thread pool) - # This prevents blocking the main async event loop. - result = await loop.run_in_executor( - None, # Use default executor - connection.send_command, # The function to call - "manage_asset", # First argument for send_command - params_dict # Second argument for send_command - ) - # Return the result obtained from Unity - return result \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py.meta deleted file mode 100644 index e0372a46..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_asset.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 7c0cfde2907ef4306b8a46c4b190f96a -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py deleted file mode 100644 index b256e6cf..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py +++ /dev/null @@ -1,53 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection - -def register_manage_editor_tools(mcp: FastMCP): - """Register all editor management tools with the MCP server.""" - - @mcp.tool() - def manage_editor( - ctx: Context, - action: str, - wait_for_completion: bool = None, - # --- Parameters for specific actions --- - tool_name: str = None, - tag_name: str = None, - layer_name: str = None, - ) -> Dict[str, Any]: - """Controls and queries the Unity editor's state and settings. - - Args: - action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag'). - wait_for_completion: Optional. If True, waits for certain actions. - Action-specific arguments (e.g., tool_name, tag_name, layer_name). - - Returns: - Dictionary with operation results ('success', 'message', 'data'). - """ - try: - # Prepare parameters, removing None values - params = { - "action": action, - "waitForCompletion": wait_for_completion, - "toolName": tool_name, # Corrected parameter name to match C# - "tagName": tag_name, # Pass tag name - "layerName": layer_name, # Pass layer name - # Add other parameters based on the action being performed - # "width": width, - # "height": height, - # etc. - } - params = {k: v for k, v in params.items() if v is not None} - - # Send command to Unity - response = get_unity_connection().send_command("manage_editor", params) - - # Process response - if response.get("success"): - return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during editor management.")} - - except Exception as e: - return {"success": False, "message": f"Python error managing editor: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py.meta deleted file mode 100644 index 1f112d77..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_editor.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 54f6646d00435410fb67cc17d095c977 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py deleted file mode 100644 index 83ab9c74..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py +++ /dev/null @@ -1,138 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any, List -from unity_connection import get_unity_connection - -def register_manage_gameobject_tools(mcp: FastMCP): - """Register all GameObject management tools with the MCP server.""" - - @mcp.tool() - def manage_gameobject( - ctx: Context, - action: str, - target: str = None, # GameObject identifier by name or path - search_method: str = None, - # --- Combined Parameters for Create/Modify --- - name: str = None, # Used for both 'create' (new object name) and 'modify' (rename) - tag: str = None, # Used for both 'create' (initial tag) and 'modify' (change tag) - parent: str = None, # Used for both 'create' (initial parent) and 'modify' (change parent) - position: List[float] = None, - rotation: List[float] = None, - scale: List[float] = None, - components_to_add: List[str] = None, # List of component names to add - primitive_type: str = None, - save_as_prefab: bool = False, - prefab_path: str = None, - prefab_folder: str = "Assets/Prefabs", - # --- Parameters for 'modify' --- - set_active: bool = None, - layer: str = None, # Layer name - components_to_remove: List[str] = None, - component_properties: Dict[str, Dict[str, Any]] = None, - # --- Parameters for 'find' --- - search_term: str = None, - find_all: bool = False, - search_in_children: bool = False, - search_inactive: bool = False, - # -- Component Management Arguments -- - component_name: str = None, - includeNonPublicSerialized: bool = None, # Controls serialization of private [SerializeField] fields - ) -> Dict[str, Any]: - """Manages GameObjects: create, modify, delete, find, and component operations. - - Args: - action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property', 'get_components'). - target: GameObject identifier (name or path string) for modify/delete/component actions. - search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups. - name: GameObject name - used for both 'create' (initial name) and 'modify' (rename). - tag: Tag name - used for both 'create' (initial tag) and 'modify' (change tag). - parent: Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent). - layer: Layer name - used for both 'create' (initial layer) and 'modify' (change layer). - component_properties: Dict mapping Component names to their properties to set. - Example: {"Rigidbody": {"mass": 10.0, "useGravity": True}}, - To set references: - - Use asset path string for Prefabs/Materials, e.g., {"MeshRenderer": {"material": "Assets/Materials/MyMat.mat"}} - - Use a dict for scene objects/components, e.g.: - {"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}} (assigns GameObject) - {"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}} (assigns Component) - Example set nested property: - - Access shared material: {"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}} - components_to_add: List of component names to add. - Action-specific arguments (e.g., position, rotation, scale for create/modify; - component_name for component actions; - search_term, find_all for 'find'). - includeNonPublicSerialized: If True, includes private fields marked [SerializeField] in component data. - - Action-specific details: - - For 'get_components': - Required: target, search_method - Optional: includeNonPublicSerialized (defaults to True) - Returns all components on the target GameObject with their serialized data. - The search_method parameter determines how to find the target ('by_name', 'by_id', 'by_path'). - - Returns: - Dictionary with operation results ('success', 'message', 'data'). - For 'get_components', the 'data' field contains a dictionary of component names and their serialized properties. - """ - try: - # --- Early check for attempting to modify a prefab asset --- - # ---------------------------------------------------------- - - # Prepare parameters, removing None values - params = { - "action": action, - "target": target, - "searchMethod": search_method, - "name": name, - "tag": tag, - "parent": parent, - "position": position, - "rotation": rotation, - "scale": scale, - "componentsToAdd": components_to_add, - "primitiveType": primitive_type, - "saveAsPrefab": save_as_prefab, - "prefabPath": prefab_path, - "prefabFolder": prefab_folder, - "setActive": set_active, - "layer": layer, - "componentsToRemove": components_to_remove, - "componentProperties": component_properties, - "searchTerm": search_term, - "findAll": find_all, - "searchInChildren": search_in_children, - "searchInactive": search_inactive, - "componentName": component_name, - "includeNonPublicSerialized": includeNonPublicSerialized - } - params = {k: v for k, v in params.items() if v is not None} - - # --- Handle Prefab Path Logic --- - if action == "create" and params.get("saveAsPrefab"): # Check if 'saveAsPrefab' is explicitly True in params - if "prefabPath" not in params: - if "name" not in params or not params["name"]: - return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} - # Use the provided prefab_folder (which has a default) and the name to construct the path - constructed_path = f"{prefab_folder}/{params['name']}.prefab" - # Ensure clean path separators (Unity prefers '/') - params["prefabPath"] = constructed_path.replace("\\", "/") - elif not params["prefabPath"].lower().endswith(".prefab"): - return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} - # Ensure prefab_folder itself isn't sent if prefabPath was constructed or provided - # The C# side only needs the final prefabPath - params.pop("prefab_folder", None) - # -------------------------------- - - # Send the command to Unity via the established connection - # Use the get_unity_connection function to retrieve the active connection instance - # Changed "MANAGE_GAMEOBJECT" to "manage_gameobject" to potentially match Unity expectation - response = get_unity_connection().send_command("manage_gameobject", params) - - # Check if the response indicates success - # If the response is not successful, raise an exception with the error message - if response.get("success"): - return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during GameObject management.")} - - except Exception as e: - return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py.meta deleted file mode 100644 index 9fc044f7..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_gameobject.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 393f17281b99c428dbe73ba8652b60f5 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py deleted file mode 100644 index 44981f65..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py +++ /dev/null @@ -1,47 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection - -def register_manage_scene_tools(mcp: FastMCP): - """Register all scene management tools with the MCP server.""" - - @mcp.tool() - def manage_scene( - ctx: Context, - action: str, - name: str, - path: str, - build_index: int, - ) -> Dict[str, Any]: - """Manages Unity scenes (load, save, create, get hierarchy, etc.). - - Args: - action: Operation (e.g., 'load', 'save', 'create', 'get_hierarchy'). - name: Scene name (no extension) for create/load/save. - path: Asset path for scene operations (default: "Assets/"). - build_index: Build index for load/build settings actions. - # Add other action-specific args as needed (e.g., for hierarchy depth) - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ - try: - params = { - "action": action, - "name": name, - "path": path, - "buildIndex": build_index - } - params = {k: v for k, v in params.items() if v is not None} - - # Send command to Unity - response = get_unity_connection().send_command("manage_scene", params) - - # Process response - if response.get("success"): - return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during scene management.")} - - except Exception as e: - return {"success": False, "message": f"Python error managing scene: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py.meta deleted file mode 100644 index a4feb8f0..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_scene.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: a744081d28b1e4ace9bfe8d6c4309640 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py deleted file mode 100644 index 22e09530..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py +++ /dev/null @@ -1,74 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection -import os -import base64 - -def register_manage_script_tools(mcp: FastMCP): - """Register all script management tools with the MCP server.""" - - @mcp.tool() - def manage_script( - ctx: Context, - action: str, - name: str, - path: str, - contents: str, - script_type: str, - namespace: str - ) -> Dict[str, Any]: - """Manages C# scripts in Unity (create, read, update, delete). - Make reference variables public for easier access in the Unity Editor. - - Args: - action: Operation ('create', 'read', 'update', 'delete'). - name: Script name (no .cs extension). - path: Asset path (default: "Assets/"). - contents: C# code for 'create'/'update'. - script_type: Type hint (e.g., 'MonoBehaviour'). - namespace: Script namespace. - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ - try: - # Prepare parameters for Unity - params = { - "action": action, - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type - } - - # Base64 encode the contents if they exist to avoid JSON escaping issues - if contents is not None: - if action in ['create', 'update']: - # Encode content for safer transmission - params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') - params["contentsEncoded"] = True - else: - params["contents"] = contents - - # Remove None values so they don't get sent as null - params = {k: v for k, v in params.items() if v is not None} - - # Send command to Unity - response = get_unity_connection().send_command("manage_script", params) - - # Process response from Unity - if response.get("success"): - # If the response contains base64 encoded content, decode it - if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') - response["data"]["contents"] = decoded_contents - del response["data"]["encodedContents"] - del response["data"]["contentsEncoded"] - - return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred.")} - - except Exception as e: - # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing script: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py.meta deleted file mode 100644 index 8ec9f2ee..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_script.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 5f5d55725198d4d53afcd4565f402b9e -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py deleted file mode 100644 index c447a3a3..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py +++ /dev/null @@ -1,67 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection -import os -import base64 - -def register_manage_shader_tools(mcp: FastMCP): - """Register all shader script management tools with the MCP server.""" - - @mcp.tool() - def manage_shader( - ctx: Context, - action: str, - name: str, - path: str, - contents: str, - ) -> Dict[str, Any]: - """Manages shader scripts in Unity (create, read, update, delete). - - Args: - action: Operation ('create', 'read', 'update', 'delete'). - name: Shader name (no .cs extension). - path: Asset path (default: "Assets/"). - contents: Shader code for 'create'/'update'. - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ - try: - # Prepare parameters for Unity - params = { - "action": action, - "name": name, - "path": path, - } - - # Base64 encode the contents if they exist to avoid JSON escaping issues - if contents is not None: - if action in ['create', 'update']: - # Encode content for safer transmission - params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') - params["contentsEncoded"] = True - else: - params["contents"] = contents - - # Remove None values so they don't get sent as null - params = {k: v for k, v in params.items() if v is not None} - - # Send command to Unity - response = get_unity_connection().send_command("manage_shader", params) - - # Process response from Unity - if response.get("success"): - # If the response contains base64 encoded content, decode it - if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') - response["data"]["contents"] = decoded_contents - del response["data"]["encodedContents"] - del response["data"]["contentsEncoded"] - - return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred.")} - - except Exception as e: - # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing shader: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py.meta deleted file mode 100644 index bdadaaa0..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/manage_shader.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 52a3e6faa53234aa08edf8163159c9af -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py deleted file mode 100644 index 3d4bd121..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -Defines the read_console tool for accessing Unity Editor console messages. -""" -from typing import List, Dict, Any -from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection - -def register_read_console_tools(mcp: FastMCP): - """Registers the read_console tool with the MCP server.""" - - @mcp.tool() - def read_console( - ctx: Context, - action: str = None, - types: List[str] = None, - count: int = None, - filter_text: str = None, - since_timestamp: str = None, - format: str = None, - include_stacktrace: bool = None - ) -> Dict[str, Any]: - """Gets messages from or clears the Unity Editor console. - - Args: - ctx: The MCP context. - action: Operation ('get' or 'clear'). - types: Message types to get ('error', 'warning', 'log', 'all'). - count: Max messages to return. - filter_text: Text filter for messages. - since_timestamp: Get messages after this timestamp (ISO 8601). - format: Output format ('plain', 'detailed', 'json'). - include_stacktrace: Include stack traces in output. - - Returns: - Dictionary with results. For 'get', includes 'data' (messages). - """ - - # Get the connection instance - bridge = get_unity_connection() - - # Set defaults if values are None - action = action if action is not None else 'get' - types = types if types is not None else ['error', 'warning', 'log'] - format = format if format is not None else 'detailed' - include_stacktrace = include_stacktrace if include_stacktrace is not None else True - - # Normalize action if it's a string - if isinstance(action, str): - action = action.lower() - - # Prepare parameters for the C# handler - params_dict = { - "action": action, - "types": types, - "count": count, - "filterText": filter_text, - "sinceTimestamp": since_timestamp, - "format": format.lower() if isinstance(format, str) else format, - "includeStacktrace": include_stacktrace - } - - # Remove None values unless it's 'count' (as None might mean 'all') - params_dict = {k: v for k, v in params_dict.items() if v is not None or k == 'count'} - - # Add count back if it was None, explicitly sending null might be important for C# logic - if 'count' not in params_dict: - params_dict['count'] = None - - # Forward the command using the bridge's send_command method - return bridge.send_command("read_console", params_dict) \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py.meta b/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py.meta deleted file mode 100644 index 3ef3e8a2..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/tools/read_console.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: a73ff5df6153548878e2656315e5db69 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer/src/unity_connection.py deleted file mode 100644 index dbf7703e..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/unity_connection.py +++ /dev/null @@ -1,239 +0,0 @@ -import socket -import json -import logging -from dataclasses import dataclass -from pathlib import Path -import time -from typing import Dict, Any -from config import config -from port_discovery import PortDiscovery - -# Configure logging using settings from config -logging.basicConfig( - level=getattr(logging, config.log_level), - format=config.log_format -) -logger = logging.getLogger("unity-mcp-server") - -@dataclass -class UnityConnection: - """Manages the socket connection to the Unity Editor.""" - host: str = config.unity_host - port: int = None # Will be set dynamically - sock: socket.socket = None # Socket for Unity communication - - def __post_init__(self): - """Set port from discovery if not explicitly provided""" - if self.port is None: - self.port = PortDiscovery.discover_unity_port() - - def connect(self) -> bool: - """Establish a connection to the Unity Editor.""" - if self.sock: - return True - try: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.connect((self.host, self.port)) - logger.info(f"Connected to Unity at {self.host}:{self.port}") - return True - except Exception as e: - logger.error(f"Failed to connect to Unity: {str(e)}") - self.sock = None - return False - - def disconnect(self): - """Close the connection to the Unity Editor.""" - if self.sock: - try: - self.sock.close() - except Exception as e: - logger.error(f"Error disconnecting from Unity: {str(e)}") - finally: - self.sock = None - - def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: - """Receive a complete response from Unity, handling chunked data.""" - chunks = [] - sock.settimeout(config.connection_timeout) # Use timeout from config - try: - while True: - chunk = sock.recv(buffer_size) - if not chunk: - if not chunks: - raise Exception("Connection closed before receiving data") - break - chunks.append(chunk) - - # Process the data received so far - data = b''.join(chunks) - decoded_data = data.decode('utf-8') - - # Check if we've received a complete response - try: - # Special case for ping-pong - if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): - logger.debug("Received ping response") - return data - - # Handle escaped quotes in the content - if '"content":' in decoded_data: - # Find the content field and its value - content_start = decoded_data.find('"content":') + 9 - content_end = decoded_data.rfind('"', content_start) - if content_end > content_start: - # Replace escaped quotes in content with regular quotes - content = decoded_data[content_start:content_end] - content = content.replace('\\"', '"') - decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:] - - # Validate JSON format - json.loads(decoded_data) - - # If we get here, we have valid JSON - logger.info(f"Received complete response ({len(data)} bytes)") - return data - except json.JSONDecodeError: - # We haven't received a complete valid JSON response yet - continue - except Exception as e: - logger.warning(f"Error processing response chunk: {str(e)}") - # Continue reading more chunks as this might not be the complete response - continue - except socket.timeout: - logger.warning("Socket timeout during receive") - raise Exception("Timeout receiving Unity response") - except Exception as e: - logger.error(f"Error during receive: {str(e)}") - raise - - def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: - """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" - attempts = max(config.max_retries, 5) - base_backoff = max(0.5, config.retry_delay) - - def read_status_file() -> dict | None: - try: - status_files = sorted(Path.home().joinpath('.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) - if not status_files: - return None - latest = status_files[0] - with latest.open('r') as f: - return json.load(f) - except Exception: - return None - - last_short_timeout = None - - for attempt in range(attempts + 1): - try: - # Ensure connected - if not self.sock: - # During retries use short connect timeout - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.settimeout(1.0) - self.sock.connect((self.host, self.port)) - # restore steady-state timeout for receive - self.sock.settimeout(config.connection_timeout) - logger.info(f"Connected to Unity at {self.host}:{self.port}") - - # Build payload - if command_type == 'ping': - payload = b'ping' - else: - command = {"type": command_type, "params": params or {}} - payload = json.dumps(command, ensure_ascii=False).encode('utf-8') - - # Send - self.sock.sendall(payload) - - # During retry bursts use a short receive timeout - if attempt > 0 and last_short_timeout is None: - last_short_timeout = self.sock.gettimeout() - self.sock.settimeout(1.0) - response_data = self.receive_full_response(self.sock) - # restore steady-state timeout if changed - if last_short_timeout is not None: - self.sock.settimeout(config.connection_timeout) - last_short_timeout = None - - # Parse - if command_type == 'ping': - resp = json.loads(response_data.decode('utf-8')) - if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong': - return {"message": "pong"} - raise Exception("Ping unsuccessful") - - resp = json.loads(response_data.decode('utf-8')) - if resp.get('status') == 'error': - err = resp.get('error') or resp.get('message', 'Unknown Unity error') - raise Exception(err) - return resp.get('result', {}) - except Exception as e: - logger.warning(f"Unity communication attempt {attempt+1} failed: {e}") - try: - if self.sock: - self.sock.close() - finally: - self.sock = None - - # Re-discover port each time - try: - new_port = PortDiscovery.discover_unity_port() - if new_port != self.port: - logger.info(f"Unity port changed {self.port} -> {new_port}") - self.port = new_port - except Exception as de: - logger.debug(f"Port discovery failed: {de}") - - if attempt < attempts: - # If heartbeat indicates reload, keep retries snappy without spamming - status = read_status_file() - backoff = base_backoff * (2 ** attempt) - sleep_s = min(backoff, 3.0) - if status and (status.get('reloading') or status.get('unity_port') == self.port): - sleep_s = min(sleep_s, 0.8) - time.sleep(sleep_s) - continue - raise - -# Global Unity connection -_unity_connection = None - -def get_unity_connection() -> UnityConnection: - """Retrieve or establish a persistent Unity connection.""" - global _unity_connection - if _unity_connection is not None: - try: - # Try to ping with a short timeout to verify connection - result = _unity_connection.send_command("ping") - # If we get here, the connection is still valid - logger.debug("Reusing existing Unity connection") - return _unity_connection - except Exception as e: - logger.warning(f"Existing connection failed: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - - # Create a new connection - logger.info("Creating new Unity connection") - _unity_connection = UnityConnection() - if not _unity_connection.connect(): - _unity_connection = None - raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") - - try: - # Verify the new connection works - _unity_connection.send_command("ping") - logger.info("Successfully established new Unity connection") - return _unity_connection - except Exception as e: - logger.error(f"Could not verify new connection: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") diff --git a/UnityMcpBridge/UnityMcpServer/src/unity_connection.py.meta b/UnityMcpBridge/UnityMcpServer/src/unity_connection.py.meta deleted file mode 100644 index e26b0321..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/unity_connection.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 2bba70ed632654291acae6c529d6ec79 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer/src/uv.lock b/UnityMcpBridge/UnityMcpServer/src/uv.lock deleted file mode 100644 index bc3e54ca..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/uv.lock +++ /dev/null @@ -1,349 +0,0 @@ -version = 1 -revision = 1 -requires-python = ">=3.12" - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, -] - -[[package]] -name = "anyio" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, -] - -[[package]] -name = "certifi" -version = "2025.1.31" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, -] - -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "h11" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, -] - -[[package]] -name = "httpcore" -version = "1.0.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, -] - -[[package]] -name = "mcp" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "uvicorn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/cc/5c5bb19f1a0f8f89a95e25cb608b0b07009e81fd4b031e519335404e1422/mcp-1.4.1.tar.gz", hash = "sha256:b9655d2de6313f9d55a7d1df62b3c3fe27a530100cc85bf23729145b0dba4c7a", size = 154942 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/0e/885f156ade60108e67bf044fada5269da68e29d758a10b0c513f4d85dd76/mcp-1.4.1-py3-none-any.whl", hash = "sha256:a7716b1ec1c054e76f49806f7d96113b99fc1166fc9244c2c6f19867cb75b593", size = 72448 }, -] - -[package.optional-dependencies] -cli = [ - { name = "python-dotenv" }, - { name = "typer" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, -] - -[[package]] -name = "pydantic" -version = "2.10.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, -] - -[[package]] -name = "pydantic-core" -version = "2.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, - { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, - { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, - { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, - { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, - { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, - { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, - { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, - { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, - { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, - { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, - { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, - { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, - { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, - { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, - { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, - { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, - { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, - { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, - { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, - { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, - { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, - { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, - { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, - { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, - { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, - { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, - { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, -] - -[[package]] -name = "pydantic-settings" -version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, -] - -[[package]] -name = "pygments" -version = "2.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, -] - -[[package]] -name = "python-dotenv" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, -] - -[[package]] -name = "rich" -version = "13.9.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, -] - -[[package]] -name = "sse-starlette" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, -] - -[[package]] -name = "starlette" -version = "0.46.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, -] - -[[package]] -name = "typer" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, -] - -[[package]] -name = "unitymcpserver" -version = "2.0.0" -source = { editable = "." } -dependencies = [ - { name = "httpx" }, - { name = "mcp", extra = ["cli"] }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.27.2" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.4.1" }, -] - -[[package]] -name = "uvicorn" -version = "0.34.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, -] diff --git a/UnityMcpBridge/UnityMcpServer/src/uv.lock.meta b/UnityMcpBridge/UnityMcpServer/src/uv.lock.meta deleted file mode 100644 index 4fa68530..00000000 --- a/UnityMcpBridge/UnityMcpServer/src/uv.lock.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 9c116a2a729ac40348fb4c81c93ea030 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index 84717103..bbb5994c 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,13 +1,4 @@ { -<<<<<<< HEAD - "name": "com.justinpbarnett.unity-mcp", - "version": "2.0.0", - "displayName": "Unity MCP Bridge", - "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", - "unity": "2020.3", - "dependencies": { - "com.unity.nuget.newtonsoft-json": "3.0.2" -======= "name": "com.coplaydev.unity-mcp", "version": "1.0.0", "displayName": "Unity MCP Bridge", @@ -31,6 +22,5 @@ "name": "CoplayDev", "email": "support@coplay.dev", "url": "https://coplay.dev" ->>>>>>> upstream/main } } diff --git a/UnityMcpServer/src/.python-version b/UnityMcpServer/src/.python-version deleted file mode 100644 index e4fba218..00000000 --- a/UnityMcpServer/src/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/UnityMcpServer/src/Dockerfile b/UnityMcpServer/src/Dockerfile deleted file mode 100644 index 3f884f37..00000000 --- a/UnityMcpServer/src/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -FROM python:3.12-slim - -# Install required system dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ - && rm -rf /var/lib/apt/lists/* - -# Set working directory -WORKDIR /app - -# Install uv package manager -RUN pip install uv - -# Copy required files -COPY config.py /app/ -COPY server.py /app/ -COPY unity_connection.py /app/ -COPY pyproject.toml /app/ -COPY __init__.py /app/ -COPY tools/ /app/tools/ - -# Install dependencies using uv -RUN uv pip install --system -e . - - -# Command to run the server -CMD ["uv", "run", "server.py"] \ No newline at end of file diff --git a/UnityMcpServer/src/__init__.py b/UnityMcpServer/src/__init__.py deleted file mode 100644 index 62e5cd1f..00000000 --- a/UnityMcpServer/src/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Unity MCP Server package. -""" \ No newline at end of file diff --git a/UnityMcpServer/src/config.py b/UnityMcpServer/src/config.py deleted file mode 100644 index 485b845d..00000000 --- a/UnityMcpServer/src/config.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Configuration settings for the Unity MCP Server. -This file contains all configurable parameters for the server. -""" - -from dataclasses import dataclass - -@dataclass -class ServerConfig: - """Main configuration class for the MCP server.""" - - # Network settings - unity_host: str = "localhost" - unity_port: int = 6400 - mcp_port: int = 6500 - - # Connection settings - connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts - buffer_size: int = 16 * 1024 * 1024 # 16MB buffer - - # Logging settings - log_level: str = "INFO" - log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - - # Server settings - max_retries: int = 8 - retry_delay: float = 0.5 - -# Create a global config instance -config = ServerConfig() \ No newline at end of file diff --git a/UnityMcpServer/src/port_discovery.py b/UnityMcpServer/src/port_discovery.py deleted file mode 100644 index 98855333..00000000 --- a/UnityMcpServer/src/port_discovery.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -Port discovery utility for Unity MCP Server. - -What changed and why: -- Unity now writes a per-project port file named like - `~/.unity-mcp/unity-mcp-port-.json` to avoid projects overwriting - each other's saved port. The legacy file `unity-mcp-port.json` may still - exist. -- This module now scans for both patterns, prefers the most recently - modified file, and verifies that the port is actually a Unity MCP listener - (quick socket connect + ping) before choosing it. -""" - -import json -import os -import logging -from pathlib import Path -from typing import Optional, List -import glob -import socket - -logger = logging.getLogger("unity-mcp-server") - -class PortDiscovery: - """Handles port discovery from Unity Bridge registry""" - REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file - DEFAULT_PORT = 6400 - CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery - - @staticmethod - def get_registry_path() -> Path: - """Get the path to the port registry file""" - return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE - - @staticmethod - def get_registry_dir() -> Path: - return Path.home() / ".unity-mcp" - - @staticmethod - def list_candidate_files() -> List[Path]: - """Return candidate registry files, newest first. - Includes hashed per-project files and the legacy file (if present). - """ - base = PortDiscovery.get_registry_dir() - hashed = sorted( - (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - legacy = PortDiscovery.get_registry_path() - if legacy.exists(): - # Put legacy at the end so hashed, per-project files win - hashed.append(legacy) - return hashed - - @staticmethod - def _try_probe_unity_mcp(port: int) -> bool: - """Quickly check if a Unity MCP listener is on this port. - Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. - """ - try: - with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: - s.settimeout(PortDiscovery.CONNECT_TIMEOUT) - try: - s.sendall(b"ping") - data = s.recv(512) - # Minimal validation: look for a success pong response - if data and b'"message":"pong"' in data: - return True - except Exception: - return False - except Exception: - return False - return False - - @staticmethod - def _read_latest_status() -> Optional[dict]: - try: - base = PortDiscovery.get_registry_dir() - status_files = sorted( - (Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - if not status_files: - return None - with status_files[0].open('r') as f: - return json.load(f) - except Exception: - return None - - @staticmethod - def discover_unity_port() -> int: - """ - Discover Unity port by scanning per-project and legacy registry files. - Prefer the newest file whose port responds; fall back to first parsed - value; finally default to 6400. - - Returns: - Port number to connect to - """ - # Prefer the latest heartbeat status if it points to a responsive port - status = PortDiscovery._read_latest_status() - if status: - port = status.get('unity_port') - if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): - logger.info(f"Using Unity port from status: {port}") - return port - - candidates = PortDiscovery.list_candidate_files() - - first_seen_port: Optional[int] = None - - for path in candidates: - try: - with open(path, 'r') as f: - cfg = json.load(f) - unity_port = cfg.get('unity_port') - if isinstance(unity_port, int): - if first_seen_port is None: - first_seen_port = unity_port - if PortDiscovery._try_probe_unity_mcp(unity_port): - logger.info(f"Using Unity port from {path.name}: {unity_port}") - return unity_port - except Exception as e: - logger.warning(f"Could not read port registry {path}: {e}") - - if first_seen_port is not None: - logger.info(f"No responsive port found; using first seen value {first_seen_port}") - return first_seen_port - - # Fallback to default port - logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") - return PortDiscovery.DEFAULT_PORT - - @staticmethod - def get_port_config() -> Optional[dict]: - """ - Get the most relevant port configuration from registry. - Returns the most recent hashed file's config if present, - otherwise the legacy file's config. Returns None if nothing exists. - - Returns: - Port configuration dict or None if not found - """ - candidates = PortDiscovery.list_candidate_files() - if not candidates: - return None - for path in candidates: - try: - with open(path, 'r') as f: - return json.load(f) - except Exception as e: - logger.warning(f"Could not read port configuration {path}: {e}") - return None \ No newline at end of file diff --git a/UnityMcpServer/src/pyproject.toml b/UnityMcpServer/src/pyproject.toml deleted file mode 100644 index 2c05fb83..00000000 --- a/UnityMcpServer/src/pyproject.toml +++ /dev/null @@ -1,15 +0,0 @@ -[project] -name = "UnityMcpServer" -version = "2.0.0" -description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." -readme = "README.md" -requires-python = ">=3.10" -dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"] - -[build-system] -requires = ["setuptools>=64.0.0", "wheel"] -build-backend = "setuptools.build_meta" - -[tool.setuptools] -py-modules = ["config", "server", "unity_connection"] -packages = ["tools"] diff --git a/UnityMcpServer/src/server.py b/UnityMcpServer/src/server.py deleted file mode 100644 index 55360b57..00000000 --- a/UnityMcpServer/src/server.py +++ /dev/null @@ -1,73 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context, Image -import logging -from dataclasses import dataclass -from contextlib import asynccontextmanager -from typing import AsyncIterator, Dict, Any, List -from config import config -from tools import register_all_tools -from unity_connection import get_unity_connection, UnityConnection - -# Configure logging using settings from config -logging.basicConfig( - level=getattr(logging, config.log_level), - format=config.log_format -) -logger = logging.getLogger("unity-mcp-server") - -# Global connection state -_unity_connection: UnityConnection = None - -@asynccontextmanager -async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: - """Handle server startup and shutdown.""" - global _unity_connection - logger.info("Unity MCP Server starting up") - try: - _unity_connection = get_unity_connection() - logger.info("Connected to Unity on startup") - except Exception as e: - logger.warning(f"Could not connect to Unity on startup: {str(e)}") - _unity_connection = None - try: - # Yield the connection object so it can be attached to the context - # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) - yield {"bridge": _unity_connection} - finally: - if _unity_connection: - _unity_connection.disconnect() - _unity_connection = None - logger.info("Unity MCP Server shut down") - -# Initialize MCP server -mcp = FastMCP( - "unity-mcp-server", - description="Unity Editor integration via Model Context Protocol", - lifespan=server_lifespan -) - -# Register all tools -register_all_tools(mcp) - -# Asset Creation Strategy - -@mcp.prompt() -def asset_creation_strategy() -> str: - """Guide for discovering and using Unity MCP tools effectively.""" - return ( - "Available Unity MCP Server Tools:\\n\\n" - "- `manage_editor`: Controls editor state and queries info.\\n" - "- `execute_menu_item`: Executes Unity Editor menu items by path.\\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" - "- `manage_script`: Manages C# script files.\\n" - "- `manage_asset`: Manages prefabs and assets.\\n" - "- `manage_shader`: Manages shaders.\\n\\n" - "Tips:\\n" - "- Create prefabs for reusable GameObjects.\\n" - "- Always include a camera and main light in your scenes.\\n" - ) - -# Run the server -if __name__ == "__main__": - mcp.run(transport='stdio') diff --git a/UnityMcpServer/src/tools/__init__.py b/UnityMcpServer/src/tools/__init__.py deleted file mode 100644 index 4d8d63cf..00000000 --- a/UnityMcpServer/src/tools/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from .manage_script import register_manage_script_tools -from .manage_scene import register_manage_scene_tools -from .manage_editor import register_manage_editor_tools -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 - -def register_all_tools(mcp): - """Register all refactored tools with the MCP server.""" - print("Registering Unity MCP Server refactored tools...") - register_manage_script_tools(mcp) - register_manage_scene_tools(mcp) - register_manage_editor_tools(mcp) - register_manage_gameobject_tools(mcp) - register_manage_asset_tools(mcp) - register_manage_shader_tools(mcp) - register_read_console_tools(mcp) - register_execute_menu_item_tools(mcp) - print("Unity MCP Server tool registration complete.") diff --git a/UnityMcpServer/src/tools/execute_menu_item.py b/UnityMcpServer/src/tools/execute_menu_item.py deleted file mode 100644 index a4ebc672..00000000 --- a/UnityMcpServer/src/tools/execute_menu_item.py +++ /dev/null @@ -1,51 +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 # Import unity_connection module - -def register_execute_menu_item_tools(mcp: FastMCP): - """Registers the execute_menu_item tool with the MCP server.""" - - @mcp.tool() - async def execute_menu_item( - ctx: Context, - 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 - - # Get Unity connection and send the command - # We use the unity_connection module to communicate with Unity - unity_conn = get_unity_connection() - - # Send command to the ExecuteMenuItem C# handler - # The command type should match what the Unity side expects - return unity_conn.send_command("execute_menu_item", params_dict) \ No newline at end of file diff --git a/UnityMcpServer/src/tools/manage_asset.py b/UnityMcpServer/src/tools/manage_asset.py deleted file mode 100644 index dada66b3..00000000 --- a/UnityMcpServer/src/tools/manage_asset.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Defines the manage_asset tool for interacting with Unity assets. -""" -import asyncio # Added: Import asyncio for running sync code in async -from typing import Dict, Any -from mcp.server.fastmcp import FastMCP, Context -# from ..unity_connection import get_unity_connection # Original line that caused error -from unity_connection import get_unity_connection # Use absolute import relative to Python dir - -def register_manage_asset_tools(mcp: FastMCP): - """Registers the manage_asset tool with the MCP server.""" - - @mcp.tool() - async def manage_asset( - ctx: Context, - action: str, - path: str, - asset_type: str = None, - properties: Dict[str, Any] = None, - destination: str = None, - generate_preview: bool = False, - search_pattern: str = None, - filter_type: str = None, - filter_date_after: str = None, - page_size: int = None, - page_number: int = None - ) -> Dict[str, Any]: - """Performs asset operations (import, create, modify, delete, etc.) in Unity. - - Args: - ctx: The MCP context. - action: Operation to perform (e.g., 'import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components'). - path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope. - asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'. - properties: Dictionary of properties for 'create'/'modify'. - example properties for Material: {"color": [1, 0, 0, 1], "shader": "Standard"}. - example properties for Texture: {"width": 1024, "height": 1024, "format": "RGBA32"}. - example properties for PhysicsMaterial: {"bounciness": 1.0, "staticFriction": 0.5, "dynamicFriction": 0.5}. - destination: Target path for 'duplicate'/'move'. - search_pattern: Search pattern (e.g., '*.prefab'). - filter_*: Filters for search (type, date). - page_*: Pagination for search. - - Returns: - A dictionary with operation results ('success', 'data', 'error'). - """ - # Ensure properties is a dict if None - if properties is None: - properties = {} - - # Prepare parameters for the C# handler - params_dict = { - "action": action.lower(), - "path": path, - "assetType": asset_type, - "properties": properties, - "destination": destination, - "generatePreview": generate_preview, - "searchPattern": search_pattern, - "filterType": filter_type, - "filterDateAfter": filter_date_after, - "pageSize": page_size, - "pageNumber": page_number - } - - # Remove None values to avoid sending unnecessary nulls - 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() - # Get the Unity connection instance - connection = get_unity_connection() - - # Run the synchronous send_command in the default executor (thread pool) - # This prevents blocking the main async event loop. - result = await loop.run_in_executor( - None, # Use default executor - connection.send_command, # The function to call - "manage_asset", # First argument for send_command - params_dict # Second argument for send_command - ) - # Return the result obtained from Unity - return result \ No newline at end of file diff --git a/UnityMcpServer/src/tools/manage_editor.py b/UnityMcpServer/src/tools/manage_editor.py deleted file mode 100644 index b256e6cf..00000000 --- a/UnityMcpServer/src/tools/manage_editor.py +++ /dev/null @@ -1,53 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection - -def register_manage_editor_tools(mcp: FastMCP): - """Register all editor management tools with the MCP server.""" - - @mcp.tool() - def manage_editor( - ctx: Context, - action: str, - wait_for_completion: bool = None, - # --- Parameters for specific actions --- - tool_name: str = None, - tag_name: str = None, - layer_name: str = None, - ) -> Dict[str, Any]: - """Controls and queries the Unity editor's state and settings. - - Args: - action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag'). - wait_for_completion: Optional. If True, waits for certain actions. - Action-specific arguments (e.g., tool_name, tag_name, layer_name). - - Returns: - Dictionary with operation results ('success', 'message', 'data'). - """ - try: - # Prepare parameters, removing None values - params = { - "action": action, - "waitForCompletion": wait_for_completion, - "toolName": tool_name, # Corrected parameter name to match C# - "tagName": tag_name, # Pass tag name - "layerName": layer_name, # Pass layer name - # Add other parameters based on the action being performed - # "width": width, - # "height": height, - # etc. - } - params = {k: v for k, v in params.items() if v is not None} - - # Send command to Unity - response = get_unity_connection().send_command("manage_editor", params) - - # Process response - if response.get("success"): - return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during editor management.")} - - except Exception as e: - return {"success": False, "message": f"Python error managing editor: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpServer/src/tools/manage_gameobject.py b/UnityMcpServer/src/tools/manage_gameobject.py deleted file mode 100644 index 83ab9c74..00000000 --- a/UnityMcpServer/src/tools/manage_gameobject.py +++ /dev/null @@ -1,138 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any, List -from unity_connection import get_unity_connection - -def register_manage_gameobject_tools(mcp: FastMCP): - """Register all GameObject management tools with the MCP server.""" - - @mcp.tool() - def manage_gameobject( - ctx: Context, - action: str, - target: str = None, # GameObject identifier by name or path - search_method: str = None, - # --- Combined Parameters for Create/Modify --- - name: str = None, # Used for both 'create' (new object name) and 'modify' (rename) - tag: str = None, # Used for both 'create' (initial tag) and 'modify' (change tag) - parent: str = None, # Used for both 'create' (initial parent) and 'modify' (change parent) - position: List[float] = None, - rotation: List[float] = None, - scale: List[float] = None, - components_to_add: List[str] = None, # List of component names to add - primitive_type: str = None, - save_as_prefab: bool = False, - prefab_path: str = None, - prefab_folder: str = "Assets/Prefabs", - # --- Parameters for 'modify' --- - set_active: bool = None, - layer: str = None, # Layer name - components_to_remove: List[str] = None, - component_properties: Dict[str, Dict[str, Any]] = None, - # --- Parameters for 'find' --- - search_term: str = None, - find_all: bool = False, - search_in_children: bool = False, - search_inactive: bool = False, - # -- Component Management Arguments -- - component_name: str = None, - includeNonPublicSerialized: bool = None, # Controls serialization of private [SerializeField] fields - ) -> Dict[str, Any]: - """Manages GameObjects: create, modify, delete, find, and component operations. - - Args: - action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property', 'get_components'). - target: GameObject identifier (name or path string) for modify/delete/component actions. - search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups. - name: GameObject name - used for both 'create' (initial name) and 'modify' (rename). - tag: Tag name - used for both 'create' (initial tag) and 'modify' (change tag). - parent: Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent). - layer: Layer name - used for both 'create' (initial layer) and 'modify' (change layer). - component_properties: Dict mapping Component names to their properties to set. - Example: {"Rigidbody": {"mass": 10.0, "useGravity": True}}, - To set references: - - Use asset path string for Prefabs/Materials, e.g., {"MeshRenderer": {"material": "Assets/Materials/MyMat.mat"}} - - Use a dict for scene objects/components, e.g.: - {"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}} (assigns GameObject) - {"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}} (assigns Component) - Example set nested property: - - Access shared material: {"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}} - components_to_add: List of component names to add. - Action-specific arguments (e.g., position, rotation, scale for create/modify; - component_name for component actions; - search_term, find_all for 'find'). - includeNonPublicSerialized: If True, includes private fields marked [SerializeField] in component data. - - Action-specific details: - - For 'get_components': - Required: target, search_method - Optional: includeNonPublicSerialized (defaults to True) - Returns all components on the target GameObject with their serialized data. - The search_method parameter determines how to find the target ('by_name', 'by_id', 'by_path'). - - Returns: - Dictionary with operation results ('success', 'message', 'data'). - For 'get_components', the 'data' field contains a dictionary of component names and their serialized properties. - """ - try: - # --- Early check for attempting to modify a prefab asset --- - # ---------------------------------------------------------- - - # Prepare parameters, removing None values - params = { - "action": action, - "target": target, - "searchMethod": search_method, - "name": name, - "tag": tag, - "parent": parent, - "position": position, - "rotation": rotation, - "scale": scale, - "componentsToAdd": components_to_add, - "primitiveType": primitive_type, - "saveAsPrefab": save_as_prefab, - "prefabPath": prefab_path, - "prefabFolder": prefab_folder, - "setActive": set_active, - "layer": layer, - "componentsToRemove": components_to_remove, - "componentProperties": component_properties, - "searchTerm": search_term, - "findAll": find_all, - "searchInChildren": search_in_children, - "searchInactive": search_inactive, - "componentName": component_name, - "includeNonPublicSerialized": includeNonPublicSerialized - } - params = {k: v for k, v in params.items() if v is not None} - - # --- Handle Prefab Path Logic --- - if action == "create" and params.get("saveAsPrefab"): # Check if 'saveAsPrefab' is explicitly True in params - if "prefabPath" not in params: - if "name" not in params or not params["name"]: - return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} - # Use the provided prefab_folder (which has a default) and the name to construct the path - constructed_path = f"{prefab_folder}/{params['name']}.prefab" - # Ensure clean path separators (Unity prefers '/') - params["prefabPath"] = constructed_path.replace("\\", "/") - elif not params["prefabPath"].lower().endswith(".prefab"): - return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} - # Ensure prefab_folder itself isn't sent if prefabPath was constructed or provided - # The C# side only needs the final prefabPath - params.pop("prefab_folder", None) - # -------------------------------- - - # Send the command to Unity via the established connection - # Use the get_unity_connection function to retrieve the active connection instance - # Changed "MANAGE_GAMEOBJECT" to "manage_gameobject" to potentially match Unity expectation - response = get_unity_connection().send_command("manage_gameobject", params) - - # Check if the response indicates success - # If the response is not successful, raise an exception with the error message - if response.get("success"): - return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during GameObject management.")} - - except Exception as e: - return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpServer/src/tools/manage_scene.py b/UnityMcpServer/src/tools/manage_scene.py deleted file mode 100644 index 44981f65..00000000 --- a/UnityMcpServer/src/tools/manage_scene.py +++ /dev/null @@ -1,47 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection - -def register_manage_scene_tools(mcp: FastMCP): - """Register all scene management tools with the MCP server.""" - - @mcp.tool() - def manage_scene( - ctx: Context, - action: str, - name: str, - path: str, - build_index: int, - ) -> Dict[str, Any]: - """Manages Unity scenes (load, save, create, get hierarchy, etc.). - - Args: - action: Operation (e.g., 'load', 'save', 'create', 'get_hierarchy'). - name: Scene name (no extension) for create/load/save. - path: Asset path for scene operations (default: "Assets/"). - build_index: Build index for load/build settings actions. - # Add other action-specific args as needed (e.g., for hierarchy depth) - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ - try: - params = { - "action": action, - "name": name, - "path": path, - "buildIndex": build_index - } - params = {k: v for k, v in params.items() if v is not None} - - # Send command to Unity - response = get_unity_connection().send_command("manage_scene", params) - - # Process response - if response.get("success"): - return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during scene management.")} - - except Exception as e: - return {"success": False, "message": f"Python error managing scene: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpServer/src/tools/manage_script.py b/UnityMcpServer/src/tools/manage_script.py deleted file mode 100644 index 22e09530..00000000 --- a/UnityMcpServer/src/tools/manage_script.py +++ /dev/null @@ -1,74 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection -import os -import base64 - -def register_manage_script_tools(mcp: FastMCP): - """Register all script management tools with the MCP server.""" - - @mcp.tool() - def manage_script( - ctx: Context, - action: str, - name: str, - path: str, - contents: str, - script_type: str, - namespace: str - ) -> Dict[str, Any]: - """Manages C# scripts in Unity (create, read, update, delete). - Make reference variables public for easier access in the Unity Editor. - - Args: - action: Operation ('create', 'read', 'update', 'delete'). - name: Script name (no .cs extension). - path: Asset path (default: "Assets/"). - contents: C# code for 'create'/'update'. - script_type: Type hint (e.g., 'MonoBehaviour'). - namespace: Script namespace. - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ - try: - # Prepare parameters for Unity - params = { - "action": action, - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type - } - - # Base64 encode the contents if they exist to avoid JSON escaping issues - if contents is not None: - if action in ['create', 'update']: - # Encode content for safer transmission - params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') - params["contentsEncoded"] = True - else: - params["contents"] = contents - - # Remove None values so they don't get sent as null - params = {k: v for k, v in params.items() if v is not None} - - # Send command to Unity - response = get_unity_connection().send_command("manage_script", params) - - # Process response from Unity - if response.get("success"): - # If the response contains base64 encoded content, decode it - if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') - response["data"]["contents"] = decoded_contents - del response["data"]["encodedContents"] - del response["data"]["contentsEncoded"] - - return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred.")} - - except Exception as e: - # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing script: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpServer/src/tools/manage_shader.py b/UnityMcpServer/src/tools/manage_shader.py deleted file mode 100644 index c447a3a3..00000000 --- a/UnityMcpServer/src/tools/manage_shader.py +++ /dev/null @@ -1,67 +0,0 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection -import os -import base64 - -def register_manage_shader_tools(mcp: FastMCP): - """Register all shader script management tools with the MCP server.""" - - @mcp.tool() - def manage_shader( - ctx: Context, - action: str, - name: str, - path: str, - contents: str, - ) -> Dict[str, Any]: - """Manages shader scripts in Unity (create, read, update, delete). - - Args: - action: Operation ('create', 'read', 'update', 'delete'). - name: Shader name (no .cs extension). - path: Asset path (default: "Assets/"). - contents: Shader code for 'create'/'update'. - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ - try: - # Prepare parameters for Unity - params = { - "action": action, - "name": name, - "path": path, - } - - # Base64 encode the contents if they exist to avoid JSON escaping issues - if contents is not None: - if action in ['create', 'update']: - # Encode content for safer transmission - params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') - params["contentsEncoded"] = True - else: - params["contents"] = contents - - # Remove None values so they don't get sent as null - params = {k: v for k, v in params.items() if v is not None} - - # Send command to Unity - response = get_unity_connection().send_command("manage_shader", params) - - # Process response from Unity - if response.get("success"): - # If the response contains base64 encoded content, decode it - if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') - response["data"]["contents"] = decoded_contents - del response["data"]["encodedContents"] - del response["data"]["contentsEncoded"] - - return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred.")} - - except Exception as e: - # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing shader: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpServer/src/tools/read_console.py b/UnityMcpServer/src/tools/read_console.py deleted file mode 100644 index 3d4bd121..00000000 --- a/UnityMcpServer/src/tools/read_console.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -Defines the read_console tool for accessing Unity Editor console messages. -""" -from typing import List, Dict, Any -from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection - -def register_read_console_tools(mcp: FastMCP): - """Registers the read_console tool with the MCP server.""" - - @mcp.tool() - def read_console( - ctx: Context, - action: str = None, - types: List[str] = None, - count: int = None, - filter_text: str = None, - since_timestamp: str = None, - format: str = None, - include_stacktrace: bool = None - ) -> Dict[str, Any]: - """Gets messages from or clears the Unity Editor console. - - Args: - ctx: The MCP context. - action: Operation ('get' or 'clear'). - types: Message types to get ('error', 'warning', 'log', 'all'). - count: Max messages to return. - filter_text: Text filter for messages. - since_timestamp: Get messages after this timestamp (ISO 8601). - format: Output format ('plain', 'detailed', 'json'). - include_stacktrace: Include stack traces in output. - - Returns: - Dictionary with results. For 'get', includes 'data' (messages). - """ - - # Get the connection instance - bridge = get_unity_connection() - - # Set defaults if values are None - action = action if action is not None else 'get' - types = types if types is not None else ['error', 'warning', 'log'] - format = format if format is not None else 'detailed' - include_stacktrace = include_stacktrace if include_stacktrace is not None else True - - # Normalize action if it's a string - if isinstance(action, str): - action = action.lower() - - # Prepare parameters for the C# handler - params_dict = { - "action": action, - "types": types, - "count": count, - "filterText": filter_text, - "sinceTimestamp": since_timestamp, - "format": format.lower() if isinstance(format, str) else format, - "includeStacktrace": include_stacktrace - } - - # Remove None values unless it's 'count' (as None might mean 'all') - params_dict = {k: v for k, v in params_dict.items() if v is not None or k == 'count'} - - # Add count back if it was None, explicitly sending null might be important for C# logic - if 'count' not in params_dict: - params_dict['count'] = None - - # Forward the command using the bridge's send_command method - return bridge.send_command("read_console", params_dict) \ No newline at end of file diff --git a/UnityMcpServer/src/unity_connection.py b/UnityMcpServer/src/unity_connection.py deleted file mode 100644 index dbf7703e..00000000 --- a/UnityMcpServer/src/unity_connection.py +++ /dev/null @@ -1,239 +0,0 @@ -import socket -import json -import logging -from dataclasses import dataclass -from pathlib import Path -import time -from typing import Dict, Any -from config import config -from port_discovery import PortDiscovery - -# Configure logging using settings from config -logging.basicConfig( - level=getattr(logging, config.log_level), - format=config.log_format -) -logger = logging.getLogger("unity-mcp-server") - -@dataclass -class UnityConnection: - """Manages the socket connection to the Unity Editor.""" - host: str = config.unity_host - port: int = None # Will be set dynamically - sock: socket.socket = None # Socket for Unity communication - - def __post_init__(self): - """Set port from discovery if not explicitly provided""" - if self.port is None: - self.port = PortDiscovery.discover_unity_port() - - def connect(self) -> bool: - """Establish a connection to the Unity Editor.""" - if self.sock: - return True - try: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.connect((self.host, self.port)) - logger.info(f"Connected to Unity at {self.host}:{self.port}") - return True - except Exception as e: - logger.error(f"Failed to connect to Unity: {str(e)}") - self.sock = None - return False - - def disconnect(self): - """Close the connection to the Unity Editor.""" - if self.sock: - try: - self.sock.close() - except Exception as e: - logger.error(f"Error disconnecting from Unity: {str(e)}") - finally: - self.sock = None - - def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: - """Receive a complete response from Unity, handling chunked data.""" - chunks = [] - sock.settimeout(config.connection_timeout) # Use timeout from config - try: - while True: - chunk = sock.recv(buffer_size) - if not chunk: - if not chunks: - raise Exception("Connection closed before receiving data") - break - chunks.append(chunk) - - # Process the data received so far - data = b''.join(chunks) - decoded_data = data.decode('utf-8') - - # Check if we've received a complete response - try: - # Special case for ping-pong - if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): - logger.debug("Received ping response") - return data - - # Handle escaped quotes in the content - if '"content":' in decoded_data: - # Find the content field and its value - content_start = decoded_data.find('"content":') + 9 - content_end = decoded_data.rfind('"', content_start) - if content_end > content_start: - # Replace escaped quotes in content with regular quotes - content = decoded_data[content_start:content_end] - content = content.replace('\\"', '"') - decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:] - - # Validate JSON format - json.loads(decoded_data) - - # If we get here, we have valid JSON - logger.info(f"Received complete response ({len(data)} bytes)") - return data - except json.JSONDecodeError: - # We haven't received a complete valid JSON response yet - continue - except Exception as e: - logger.warning(f"Error processing response chunk: {str(e)}") - # Continue reading more chunks as this might not be the complete response - continue - except socket.timeout: - logger.warning("Socket timeout during receive") - raise Exception("Timeout receiving Unity response") - except Exception as e: - logger.error(f"Error during receive: {str(e)}") - raise - - def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: - """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" - attempts = max(config.max_retries, 5) - base_backoff = max(0.5, config.retry_delay) - - def read_status_file() -> dict | None: - try: - status_files = sorted(Path.home().joinpath('.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) - if not status_files: - return None - latest = status_files[0] - with latest.open('r') as f: - return json.load(f) - except Exception: - return None - - last_short_timeout = None - - for attempt in range(attempts + 1): - try: - # Ensure connected - if not self.sock: - # During retries use short connect timeout - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.settimeout(1.0) - self.sock.connect((self.host, self.port)) - # restore steady-state timeout for receive - self.sock.settimeout(config.connection_timeout) - logger.info(f"Connected to Unity at {self.host}:{self.port}") - - # Build payload - if command_type == 'ping': - payload = b'ping' - else: - command = {"type": command_type, "params": params or {}} - payload = json.dumps(command, ensure_ascii=False).encode('utf-8') - - # Send - self.sock.sendall(payload) - - # During retry bursts use a short receive timeout - if attempt > 0 and last_short_timeout is None: - last_short_timeout = self.sock.gettimeout() - self.sock.settimeout(1.0) - response_data = self.receive_full_response(self.sock) - # restore steady-state timeout if changed - if last_short_timeout is not None: - self.sock.settimeout(config.connection_timeout) - last_short_timeout = None - - # Parse - if command_type == 'ping': - resp = json.loads(response_data.decode('utf-8')) - if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong': - return {"message": "pong"} - raise Exception("Ping unsuccessful") - - resp = json.loads(response_data.decode('utf-8')) - if resp.get('status') == 'error': - err = resp.get('error') or resp.get('message', 'Unknown Unity error') - raise Exception(err) - return resp.get('result', {}) - except Exception as e: - logger.warning(f"Unity communication attempt {attempt+1} failed: {e}") - try: - if self.sock: - self.sock.close() - finally: - self.sock = None - - # Re-discover port each time - try: - new_port = PortDiscovery.discover_unity_port() - if new_port != self.port: - logger.info(f"Unity port changed {self.port} -> {new_port}") - self.port = new_port - except Exception as de: - logger.debug(f"Port discovery failed: {de}") - - if attempt < attempts: - # If heartbeat indicates reload, keep retries snappy without spamming - status = read_status_file() - backoff = base_backoff * (2 ** attempt) - sleep_s = min(backoff, 3.0) - if status and (status.get('reloading') or status.get('unity_port') == self.port): - sleep_s = min(sleep_s, 0.8) - time.sleep(sleep_s) - continue - raise - -# Global Unity connection -_unity_connection = None - -def get_unity_connection() -> UnityConnection: - """Retrieve or establish a persistent Unity connection.""" - global _unity_connection - if _unity_connection is not None: - try: - # Try to ping with a short timeout to verify connection - result = _unity_connection.send_command("ping") - # If we get here, the connection is still valid - logger.debug("Reusing existing Unity connection") - return _unity_connection - except Exception as e: - logger.warning(f"Existing connection failed: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - - # Create a new connection - logger.info("Creating new Unity connection") - _unity_connection = UnityConnection() - if not _unity_connection.connect(): - _unity_connection = None - raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") - - try: - # Verify the new connection works - _unity_connection.send_command("ping") - logger.info("Successfully established new Unity connection") - return _unity_connection - except Exception as e: - logger.error(f"Could not verify new connection: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") diff --git a/UnityMcpServer/src/uv.lock b/UnityMcpServer/src/uv.lock deleted file mode 100644 index de0cd446..00000000 --- a/UnityMcpServer/src/uv.lock +++ /dev/null @@ -1,400 +0,0 @@ -version = 1 -revision = 1 -requires-python = ">=3.10" - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, -] - -[[package]] -name = "anyio" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, -] - -[[package]] -name = "certifi" -version = "2025.1.31" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, -] - -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, -] - -[[package]] -name = "h11" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, -] - -[[package]] -name = "httpcore" -version = "1.0.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, -] - -[[package]] -name = "mcp" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "uvicorn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/cc/5c5bb19f1a0f8f89a95e25cb608b0b07009e81fd4b031e519335404e1422/mcp-1.4.1.tar.gz", hash = "sha256:b9655d2de6313f9d55a7d1df62b3c3fe27a530100cc85bf23729145b0dba4c7a", size = 154942 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/0e/885f156ade60108e67bf044fada5269da68e29d758a10b0c513f4d85dd76/mcp-1.4.1-py3-none-any.whl", hash = "sha256:a7716b1ec1c054e76f49806f7d96113b99fc1166fc9244c2c6f19867cb75b593", size = 72448 }, -] - -[package.optional-dependencies] -cli = [ - { name = "python-dotenv" }, - { name = "typer" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, -] - -[[package]] -name = "pydantic" -version = "2.10.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, -] - -[[package]] -name = "pydantic-core" -version = "2.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, - { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, - { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, - { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, - { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, - { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, - { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, - { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, - { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, - { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, - { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, - { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, - { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, - { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, - { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, - { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, - { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, - { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, - { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, - { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, - { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, - { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, - { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, - { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, - { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, - { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, - { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, - { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, - { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, - { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, - { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, - { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, - { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, - { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, - { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, - { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, - { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, - { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, - { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, - { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, - { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, - { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, - { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, - { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, - { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, - { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, - { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, - { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, - { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, - { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, - { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, - { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, - { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, - { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, - { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, - { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, - { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, - { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, - { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, - { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, - { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, - { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, - { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, - { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, -] - -[[package]] -name = "pydantic-settings" -version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, -] - -[[package]] -name = "pygments" -version = "2.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, -] - -[[package]] -name = "python-dotenv" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, -] - -[[package]] -name = "rich" -version = "13.9.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, -] - -[[package]] -name = "sse-starlette" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, -] - -[[package]] -name = "starlette" -version = "0.46.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, -] - -[[package]] -name = "typer" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, -] - -[[package]] -name = "unitymcpserver" -version = "2.0.0" -source = { editable = "." } -dependencies = [ - { name = "httpx" }, - { name = "mcp", extra = ["cli"] }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.27.2" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.4.1" }, -] - -[[package]] -name = "uvicorn" -version = "0.34.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, -] diff --git a/deploy-dev.bat b/deploy-dev.bat index 6a83fcf0..2b04c22b 100644 --- a/deploy-dev.bat +++ b/deploy-dev.bat @@ -9,7 +9,7 @@ echo. :: Configuration set "SCRIPT_DIR=%~dp0" set "BRIDGE_SOURCE=%SCRIPT_DIR%UnityMcpBridge" -set "SERVER_SOURCE=%SCRIPT_DIR%UnityMcpServer\src" +set "SERVER_SOURCE=%SCRIPT_DIR%UnityMcpBridge\UnityMcpServer~\src" set "DEFAULT_BACKUP_DIR=%USERPROFILE%\Desktop\unity-mcp-backup" set "DEFAULT_SERVER_PATH=%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src" diff --git a/restore-dev.bat b/restore-dev.bat index 69d9312b..553ccc12 100644 --- a/restore-dev.bat +++ b/restore-dev.bat @@ -5,6 +5,9 @@ echo =============================================== echo Unity MCP Development Restore Script echo =============================================== echo. +echo Note: The Python server is bundled under UnityMcpBridge\UnityMcpServer~ in the package. +echo This script restores your installed server path from backups, not the repo copy. +echo. :: Configuration set "DEFAULT_BACKUP_DIR=%USERPROFILE%\Desktop\unity-mcp-backup" From 07b35837b74ded00bde58ec6e4a436e4d2f7dd1a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 9 Aug 2025 15:08:28 -0700 Subject: [PATCH 32/69] Bridge: deferred init, stop-before-reload, breadcrumb logs; stable rebinds. Editor: auto-rewrite MCP client config when package path changes. Server: heartbeat-aware retries, structured {state: reloading, retry_after_ms}, single auto-retry across tools; guard empty calls. Repo: remove global *~ ignore (was hiding UnityMcpServer~), track tilde server folder (Unity still excludes it from assemblies). --- UnityMcpBridge/Editor/UnityMcpBridge.cs | 139 ++++++- .../Editor/Windows/UnityMcpEditorWindow.cs | 30 +- UnityMcpBridge/UnityMcpServer~/src/Dockerfile | 27 ++ .../UnityMcpServer~/src/__init__.py | 3 + UnityMcpBridge/UnityMcpServer~/src/config.py | 30 ++ .../UnityMcpServer~/src/port_discovery.py | 155 ++++++++ .../UnityMcpServer~/src/pyproject.toml | 15 + UnityMcpBridge/UnityMcpServer~/src/server.py | 73 ++++ .../UnityMcpServer~/src/tools/__init__.py | 21 ++ .../src/tools/execute_menu_item.py | 57 +++ .../UnityMcpServer~/src/tools/manage_asset.py | 93 +++++ .../src/tools/manage_editor.py | 58 +++ .../src/tools/manage_gameobject.py | 143 +++++++ .../UnityMcpServer~/src/tools/manage_scene.py | 52 +++ .../src/tools/manage_script.py | 79 ++++ .../src/tools/manage_shader.py | 72 ++++ .../UnityMcpServer~/src/tools/read_console.py | 76 ++++ .../UnityMcpServer~/src/unity_connection.py | 280 ++++++++++++++ UnityMcpBridge/UnityMcpServer~/src/uv.lock | 349 ++++++++++++++++++ 19 files changed, 1742 insertions(+), 10 deletions(-) create mode 100644 UnityMcpBridge/UnityMcpServer~/src/Dockerfile create mode 100644 UnityMcpBridge/UnityMcpServer~/src/__init__.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/config.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/port_discovery.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/pyproject.toml create mode 100644 UnityMcpBridge/UnityMcpServer~/src/server.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/unity_connection.py create mode 100644 UnityMcpBridge/UnityMcpServer~/src/uv.lock diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 89bae4e5..b7e8ef0e 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -24,13 +24,31 @@ public static partial class UnityMcpBridge private static readonly object lockObj = new(); private static readonly object startStopLock = new(); private static bool initScheduled = false; + private static bool ensureUpdateHooked = false; + private static bool isStarting = false; + private static double nextStartAt = 0.0f; private static double nextHeartbeatAt = 0.0f; + private static int heartbeatSeq = 0; private static Dictionary< string, (string commandJson, TaskCompletionSource tcs) > commandQueue = new(); private static int currentUnityPort = 6400; // Dynamic port, starts with default private static bool isAutoConnectMode = false; + + // Debug helpers + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("UnityMCP.DebugLogs", false); } catch { return false; } + } + + private static void LogBreadcrumb(string stage) + { + if (IsDebugEnabled()) + { + Debug.Log($"UNITY-MCP: [{stage}]"); + } + } public static bool IsRunning => isRunning; public static int GetCurrentPort() => currentUnityPort; @@ -78,11 +96,24 @@ public static bool FolderExists(string path) static UnityMcpBridge() { - // Immediate start for minimal downtime, plus quit hook - Start(); + // Skip bridge in headless/batch environments (CI/builds) + if (Application.isBatchMode) + { + return; + } + // Defer start until the editor is idle and not compiling + ScheduleInitRetry(); + // Add a safety net update hook in case delayCall is missed during reload churn + if (!ensureUpdateHooked) + { + ensureUpdateHooked = true; + EditorApplication.update += EnsureStartedOnEditorIdle; + } EditorApplication.quitting += Stop; AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + // Also coalesce play mode transitions into a deferred init + EditorApplication.playModeStateChanged += _ => ScheduleInitRetry(); } /// @@ -94,7 +125,7 @@ private static void InitializeAfterCompilation() initScheduled = false; // Play-mode friendly: allow starting in play mode; only defer while compiling - if (EditorApplication.isCompiling) + if (IsCompiling()) { ScheduleInitRetry(); return; @@ -118,9 +149,77 @@ private static void ScheduleInitRetry() return; } initScheduled = true; + // Debounce: start ~200ms after the last trigger + nextStartAt = EditorApplication.timeSinceStartup + 0.20f; + // Ensure the update pump is active + if (!ensureUpdateHooked) + { + ensureUpdateHooked = true; + EditorApplication.update += EnsureStartedOnEditorIdle; + } + // Keep the original delayCall as a secondary path EditorApplication.delayCall += InitializeAfterCompilation; } + // Safety net: ensure the bridge starts shortly after domain reload when editor is idle + private static void EnsureStartedOnEditorIdle() + { + // Do nothing while compiling + if (IsCompiling()) + { + return; + } + + // If already running, remove the hook + if (isRunning) + { + EditorApplication.update -= EnsureStartedOnEditorIdle; + ensureUpdateHooked = false; + return; + } + + // Debounced start: wait until the scheduled time + if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt) + { + return; + } + + if (isStarting) + { + return; + } + + isStarting = true; + // Attempt start; if it succeeds, remove the hook to avoid overhead + Start(); + isStarting = false; + if (isRunning) + { + EditorApplication.update -= EnsureStartedOnEditorIdle; + ensureUpdateHooked = false; + } + } + + // Helper to check compilation status across Unity versions + private static bool IsCompiling() + { + if (EditorApplication.isCompiling) + { + return true; + } + try + { + System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); + var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (prop != null) + { + return (bool)prop.GetValue(null); + } + } + catch { } + return false; + } + public static void Start() { lock (startStopLock) @@ -140,6 +239,9 @@ public static void Start() // Always consult PortManager first so we prefer the persisted project port currentUnityPort = PortManager.GetPortWithFallback(); + // Breadcrumb: Start + LogBreadcrumb("Start"); + const int maxImmediateRetries = 3; const int retrySleepMs = 75; int attempt = 0; @@ -153,6 +255,13 @@ public static void Start() SocketOptionName.ReuseAddress, true ); +#if UNITY_EDITOR_WIN + try + { + listener.ExclusiveAddressUse = false; + } + catch { } +#endif // Minimize TIME_WAIT by sending RST on close try { @@ -180,6 +289,13 @@ public static void Start() SocketOptionName.ReuseAddress, true ); +#if UNITY_EDITOR_WIN + try + { + listener.ExclusiveAddressUse = false; + } + catch { } +#endif try { listener.Server.LingerState = new LingerOption(true, 0); @@ -198,7 +314,8 @@ public static void Start() Task.Run(ListenerLoop); EditorApplication.update += ProcessCommands; // Write initial heartbeat immediately - WriteHeartbeat(false); + heartbeatSeq++; + WriteHeartbeat(false, "ready"); nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; } catch (SocketException ex) @@ -571,16 +688,22 @@ private static string GetParamsSummary(JObject @params) // Heartbeat/status helpers private static void OnBeforeAssemblyReload() { - WriteHeartbeat(true); + // Stop cleanly before reload so sockets close and clients see 'reloading' + try { Stop(); } catch { } + WriteHeartbeat(true, "reloading"); + LogBreadcrumb("Reload"); } private static void OnAfterAssemblyReload() { // Will be overwritten by Start(), but mark as alive quickly - WriteHeartbeat(false); + WriteHeartbeat(false, "idle"); + LogBreadcrumb("Idle"); + // Schedule a safe restart after reload to avoid races during compilation + ScheduleInitRetry(); } - private static void WriteHeartbeat(bool reloading) + private static void WriteHeartbeat(bool reloading, string reason = null) { try { @@ -591,6 +714,8 @@ private static void WriteHeartbeat(bool reloading) { unity_port = currentUnityPort, reloading, + reason = reason ?? (reloading ? "reloading" : "ready"), + seq = heartbeatSeq, project_path = Application.dataPath, last_heartbeat = DateTime.UtcNow.ToString("O") }; diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 5dde570c..1f9cbccd 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1311,14 +1311,38 @@ private void CheckMcpConfiguration(McpClient mcpClient) // Common logic for checking configuration status if (configExists) { - if (pythonDir != null && - Array.Exists(args, arg => arg.Contains(pythonDir, StringComparison.Ordinal))) + bool matches = pythonDir != null && Array.Exists(args, arg => arg.Contains(pythonDir, StringComparison.Ordinal)); + if (matches) { mcpClient.SetStatus(McpStatus.Configured); } else { - mcpClient.SetStatus(McpStatus.IncorrectPath); + // Attempt auto-rewrite once if the package path changed + try + { + string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient); + if (rewriteResult == "Configured successfully") + { + if (debugLogsEnabled) + { + UnityEngine.Debug.Log($"UnityMCP: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}"); + } + mcpClient.SetStatus(McpStatus.Configured); + } + else + { + mcpClient.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + mcpClient.SetStatus(McpStatus.IncorrectPath); + if (debugLogsEnabled) + { + UnityEngine.Debug.LogWarning($"UnityMCP: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); + } + } } } else diff --git a/UnityMcpBridge/UnityMcpServer~/src/Dockerfile b/UnityMcpBridge/UnityMcpServer~/src/Dockerfile new file mode 100644 index 00000000..3f884f37 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim + +# Install required system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Install uv package manager +RUN pip install uv + +# Copy required files +COPY config.py /app/ +COPY server.py /app/ +COPY unity_connection.py /app/ +COPY pyproject.toml /app/ +COPY __init__.py /app/ +COPY tools/ /app/tools/ + +# Install dependencies using uv +RUN uv pip install --system -e . + + +# Command to run the server +CMD ["uv", "run", "server.py"] \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/__init__.py new file mode 100644 index 00000000..62e5cd1f --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/__init__.py @@ -0,0 +1,3 @@ +""" +Unity MCP Server package. +""" \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/config.py b/UnityMcpBridge/UnityMcpServer~/src/config.py new file mode 100644 index 00000000..d3a203c7 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/config.py @@ -0,0 +1,30 @@ +""" +Configuration settings for the Unity MCP Server. +This file contains all configurable parameters for the server. +""" + +from dataclasses import dataclass + +@dataclass +class ServerConfig: + """Main configuration class for the MCP server.""" + + # Network settings + unity_host: str = "localhost" + unity_port: int = 6400 + mcp_port: int = 6500 + + # Connection settings + connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts + buffer_size: int = 16 * 1024 * 1024 # 16MB buffer + + # Logging settings + log_level: str = "INFO" + log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + # Server settings + max_retries: int = 10 + retry_delay: float = 0.25 + +# Create a global config instance +config = ServerConfig() \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py b/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py new file mode 100644 index 00000000..98855333 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py @@ -0,0 +1,155 @@ +""" +Port discovery utility for Unity MCP Server. + +What changed and why: +- Unity now writes a per-project port file named like + `~/.unity-mcp/unity-mcp-port-.json` to avoid projects overwriting + each other's saved port. The legacy file `unity-mcp-port.json` may still + exist. +- This module now scans for both patterns, prefers the most recently + modified file, and verifies that the port is actually a Unity MCP listener + (quick socket connect + ping) before choosing it. +""" + +import json +import os +import logging +from pathlib import Path +from typing import Optional, List +import glob +import socket + +logger = logging.getLogger("unity-mcp-server") + +class PortDiscovery: + """Handles port discovery from Unity Bridge registry""" + REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file + DEFAULT_PORT = 6400 + CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery + + @staticmethod + def get_registry_path() -> Path: + """Get the path to the port registry file""" + return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE + + @staticmethod + def get_registry_dir() -> Path: + return Path.home() / ".unity-mcp" + + @staticmethod + def list_candidate_files() -> List[Path]: + """Return candidate registry files, newest first. + Includes hashed per-project files and the legacy file (if present). + """ + base = PortDiscovery.get_registry_dir() + hashed = sorted( + (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + legacy = PortDiscovery.get_registry_path() + if legacy.exists(): + # Put legacy at the end so hashed, per-project files win + hashed.append(legacy) + return hashed + + @staticmethod + def _try_probe_unity_mcp(port: int) -> bool: + """Quickly check if a Unity MCP listener is on this port. + Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. + """ + try: + with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: + s.settimeout(PortDiscovery.CONNECT_TIMEOUT) + try: + s.sendall(b"ping") + data = s.recv(512) + # Minimal validation: look for a success pong response + if data and b'"message":"pong"' in data: + return True + except Exception: + return False + except Exception: + return False + return False + + @staticmethod + def _read_latest_status() -> Optional[dict]: + try: + base = PortDiscovery.get_registry_dir() + status_files = sorted( + (Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not status_files: + return None + with status_files[0].open('r') as f: + return json.load(f) + except Exception: + return None + + @staticmethod + def discover_unity_port() -> int: + """ + Discover Unity port by scanning per-project and legacy registry files. + Prefer the newest file whose port responds; fall back to first parsed + value; finally default to 6400. + + Returns: + Port number to connect to + """ + # Prefer the latest heartbeat status if it points to a responsive port + status = PortDiscovery._read_latest_status() + if status: + port = status.get('unity_port') + if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): + logger.info(f"Using Unity port from status: {port}") + return port + + candidates = PortDiscovery.list_candidate_files() + + first_seen_port: Optional[int] = None + + for path in candidates: + try: + with open(path, 'r') as f: + cfg = json.load(f) + unity_port = cfg.get('unity_port') + if isinstance(unity_port, int): + if first_seen_port is None: + first_seen_port = unity_port + if PortDiscovery._try_probe_unity_mcp(unity_port): + logger.info(f"Using Unity port from {path.name}: {unity_port}") + return unity_port + except Exception as e: + logger.warning(f"Could not read port registry {path}: {e}") + + if first_seen_port is not None: + logger.info(f"No responsive port found; using first seen value {first_seen_port}") + return first_seen_port + + # Fallback to default port + logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") + return PortDiscovery.DEFAULT_PORT + + @staticmethod + def get_port_config() -> Optional[dict]: + """ + Get the most relevant port configuration from registry. + Returns the most recent hashed file's config if present, + otherwise the legacy file's config. Returns None if nothing exists. + + Returns: + Port configuration dict or None if not found + """ + candidates = PortDiscovery.list_candidate_files() + if not candidates: + return None + for path in candidates: + try: + with open(path, 'r') as f: + return json.load(f) + except Exception as e: + logger.warning(f"Could not read port configuration {path}: {e}") + return None \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml b/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml new file mode 100644 index 00000000..2c05fb83 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "UnityMcpServer" +version = "2.0.0" +description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"] + +[build-system] +requires = ["setuptools>=64.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = ["config", "server", "unity_connection"] +packages = ["tools"] diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py new file mode 100644 index 00000000..55360b57 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -0,0 +1,73 @@ +from mcp.server.fastmcp import FastMCP, Context, Image +import logging +from dataclasses import dataclass +from contextlib import asynccontextmanager +from typing import AsyncIterator, Dict, Any, List +from config import config +from tools import register_all_tools +from unity_connection import get_unity_connection, UnityConnection + +# Configure logging using settings from config +logging.basicConfig( + level=getattr(logging, config.log_level), + format=config.log_format +) +logger = logging.getLogger("unity-mcp-server") + +# Global connection state +_unity_connection: UnityConnection = None + +@asynccontextmanager +async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: + """Handle server startup and shutdown.""" + global _unity_connection + logger.info("Unity MCP Server starting up") + try: + _unity_connection = get_unity_connection() + logger.info("Connected to Unity on startup") + except Exception as e: + logger.warning(f"Could not connect to Unity on startup: {str(e)}") + _unity_connection = None + try: + # Yield the connection object so it can be attached to the context + # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) + yield {"bridge": _unity_connection} + finally: + if _unity_connection: + _unity_connection.disconnect() + _unity_connection = None + logger.info("Unity MCP Server shut down") + +# Initialize MCP server +mcp = FastMCP( + "unity-mcp-server", + description="Unity Editor integration via Model Context Protocol", + lifespan=server_lifespan +) + +# Register all tools +register_all_tools(mcp) + +# Asset Creation Strategy + +@mcp.prompt() +def asset_creation_strategy() -> str: + """Guide for discovering and using Unity MCP tools effectively.""" + return ( + "Available Unity MCP Server Tools:\\n\\n" + "- `manage_editor`: Controls editor state and queries info.\\n" + "- `execute_menu_item`: Executes Unity Editor menu items by path.\\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" + "- `manage_script`: Manages C# script files.\\n" + "- `manage_asset`: Manages prefabs and assets.\\n" + "- `manage_shader`: Manages shaders.\\n\\n" + "Tips:\\n" + "- Create prefabs for reusable GameObjects.\\n" + "- Always include a camera and main light in your scenes.\\n" + ) + +# Run the server +if __name__ == "__main__": + mcp.run(transport='stdio') diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py new file mode 100644 index 00000000..4d8d63cf --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -0,0 +1,21 @@ +from .manage_script import register_manage_script_tools +from .manage_scene import register_manage_scene_tools +from .manage_editor import register_manage_editor_tools +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 + +def register_all_tools(mcp): + """Register all refactored tools with the MCP server.""" + print("Registering Unity MCP Server refactored tools...") + register_manage_script_tools(mcp) + register_manage_scene_tools(mcp) + register_manage_editor_tools(mcp) + register_manage_gameobject_tools(mcp) + register_manage_asset_tools(mcp) + register_manage_shader_tools(mcp) + register_read_console_tools(mcp) + register_execute_menu_item_tools(mcp) + print("Unity MCP Server tool registration complete.") diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py new file mode 100644 index 00000000..10af5292 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py @@ -0,0 +1,57 @@ +""" +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 # Import unity_connection module +import time + +def register_execute_menu_item_tools(mcp: FastMCP): + """Registers the execute_menu_item tool with the MCP server.""" + + @mcp.tool() + async def execute_menu_item( + ctx: Context, + 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 + + # Get Unity connection and send the command + # We use the unity_connection module to communicate with Unity + unity_conn = get_unity_connection() + + # Send command to the ExecuteMenuItem C# handler + # The command type should match what the Unity side expects + resp = unity_conn.send_command("execute_menu_item", params_dict) + if isinstance(resp, dict) and not resp.get("success", True) and resp.get("state") == "reloading": + delay_ms = int(resp.get("retry_after_ms", 250)) + time.sleep(max(0.0, delay_ms / 1000.0)) + resp = unity_conn.send_command("execute_menu_item", params_dict) + return resp \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py new file mode 100644 index 00000000..53c14707 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -0,0 +1,93 @@ +""" +Defines the manage_asset tool for interacting with Unity assets. +""" +import asyncio # Added: Import asyncio for running sync code in async +from typing import Dict, Any +from mcp.server.fastmcp import FastMCP, Context +# from ..unity_connection import get_unity_connection # Original line that caused error +from unity_connection import get_unity_connection # Use absolute import relative to Python dir +import time + +def register_manage_asset_tools(mcp: FastMCP): + """Registers the manage_asset tool with the MCP server.""" + + @mcp.tool() + async def manage_asset( + ctx: Context, + action: str, + path: str, + asset_type: str = None, + properties: Dict[str, Any] = None, + destination: str = None, + generate_preview: bool = False, + search_pattern: str = None, + filter_type: str = None, + filter_date_after: str = None, + page_size: int = None, + page_number: int = None + ) -> Dict[str, Any]: + """Performs asset operations (import, create, modify, delete, etc.) in Unity. + + Args: + ctx: The MCP context. + action: Operation to perform (e.g., 'import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components'). + path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope. + asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'. + properties: Dictionary of properties for 'create'/'modify'. + example properties for Material: {"color": [1, 0, 0, 1], "shader": "Standard"}. + example properties for Texture: {"width": 1024, "height": 1024, "format": "RGBA32"}. + example properties for PhysicsMaterial: {"bounciness": 1.0, "staticFriction": 0.5, "dynamicFriction": 0.5}. + destination: Target path for 'duplicate'/'move'. + search_pattern: Search pattern (e.g., '*.prefab'). + filter_*: Filters for search (type, date). + page_*: Pagination for search. + + Returns: + A dictionary with operation results ('success', 'data', 'error'). + """ + # Ensure properties is a dict if None + if properties is None: + properties = {} + + # Prepare parameters for the C# handler + params_dict = { + "action": action.lower(), + "path": path, + "assetType": asset_type, + "properties": properties, + "destination": destination, + "generatePreview": generate_preview, + "searchPattern": search_pattern, + "filterType": filter_type, + "filterDateAfter": filter_date_after, + "pageSize": page_size, + "pageNumber": page_number + } + + # Remove None values to avoid sending unnecessary nulls + 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() + # Get the Unity connection instance + connection = get_unity_connection() + + # Run the synchronous send_command in the default executor (thread pool) + # This prevents blocking the main async event loop. + result = await loop.run_in_executor( + None, # Use default executor + connection.send_command, # The function to call + "manage_asset", # First argument for send_command + params_dict # Second argument for send_command + ) + if isinstance(result, dict) and not result.get("success", True) and result.get("state") == "reloading": + delay_ms = int(result.get("retry_after_ms", 250)) + await asyncio.sleep(max(0.0, delay_ms / 1000.0)) + result = await loop.run_in_executor( + None, + connection.send_command, + "manage_asset", + params_dict + ) + # Return the result obtained from Unity + return result \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py new file mode 100644 index 00000000..9c347d06 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -0,0 +1,58 @@ +from mcp.server.fastmcp import FastMCP, Context +import time +from typing import Dict, Any +from unity_connection import get_unity_connection + +def register_manage_editor_tools(mcp: FastMCP): + """Register all editor management tools with the MCP server.""" + + @mcp.tool() + def manage_editor( + ctx: Context, + action: str, + wait_for_completion: bool = None, + # --- Parameters for specific actions --- + tool_name: str = None, + tag_name: str = None, + layer_name: str = None, + ) -> Dict[str, Any]: + """Controls and queries the Unity editor's state and settings. + + Args: + action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag'). + wait_for_completion: Optional. If True, waits for certain actions. + Action-specific arguments (e.g., tool_name, tag_name, layer_name). + + Returns: + Dictionary with operation results ('success', 'message', 'data'). + """ + try: + # Prepare parameters, removing None values + params = { + "action": action, + "waitForCompletion": wait_for_completion, + "toolName": tool_name, # Corrected parameter name to match C# + "tagName": tag_name, # Pass tag name + "layerName": layer_name, # Pass layer name + # Add other parameters based on the action being performed + # "width": width, + # "height": height, + # etc. + } + params = {k: v for k, v in params.items() if v is not None} + + # Send command to Unity (with a single polite retry if reloading) + response = get_unity_connection().send_command("manage_editor", params) + if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading": + delay_ms = int(response.get("retry_after_ms", 250)) + time.sleep(max(0.0, delay_ms / 1000.0)) + response = get_unity_connection().send_command("manage_editor", params) + + # Process response + if response.get("success"): + return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred during editor management.")} + + except Exception as e: + return {"success": False, "message": f"Python error managing editor: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py new file mode 100644 index 00000000..37871486 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -0,0 +1,143 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any, List +from unity_connection import get_unity_connection +import time + +def register_manage_gameobject_tools(mcp: FastMCP): + """Register all GameObject management tools with the MCP server.""" + + @mcp.tool() + def manage_gameobject( + ctx: Context, + action: str, + target: str = None, # GameObject identifier by name or path + search_method: str = None, + # --- Combined Parameters for Create/Modify --- + name: str = None, # Used for both 'create' (new object name) and 'modify' (rename) + tag: str = None, # Used for both 'create' (initial tag) and 'modify' (change tag) + parent: str = None, # Used for both 'create' (initial parent) and 'modify' (change parent) + position: List[float] = None, + rotation: List[float] = None, + scale: List[float] = None, + components_to_add: List[str] = None, # List of component names to add + primitive_type: str = None, + save_as_prefab: bool = False, + prefab_path: str = None, + prefab_folder: str = "Assets/Prefabs", + # --- Parameters for 'modify' --- + set_active: bool = None, + layer: str = None, # Layer name + components_to_remove: List[str] = None, + component_properties: Dict[str, Dict[str, Any]] = None, + # --- Parameters for 'find' --- + search_term: str = None, + find_all: bool = False, + search_in_children: bool = False, + search_inactive: bool = False, + # -- Component Management Arguments -- + component_name: str = None, + includeNonPublicSerialized: bool = None, # Controls serialization of private [SerializeField] fields + ) -> Dict[str, Any]: + """Manages GameObjects: create, modify, delete, find, and component operations. + + Args: + action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property', 'get_components'). + target: GameObject identifier (name or path string) for modify/delete/component actions. + search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups. + name: GameObject name - used for both 'create' (initial name) and 'modify' (rename). + tag: Tag name - used for both 'create' (initial tag) and 'modify' (change tag). + parent: Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent). + layer: Layer name - used for both 'create' (initial layer) and 'modify' (change layer). + component_properties: Dict mapping Component names to their properties to set. + Example: {"Rigidbody": {"mass": 10.0, "useGravity": True}}, + To set references: + - Use asset path string for Prefabs/Materials, e.g., {"MeshRenderer": {"material": "Assets/Materials/MyMat.mat"}} + - Use a dict for scene objects/components, e.g.: + {"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}} (assigns GameObject) + {"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}} (assigns Component) + Example set nested property: + - Access shared material: {"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}} + components_to_add: List of component names to add. + Action-specific arguments (e.g., position, rotation, scale for create/modify; + component_name for component actions; + search_term, find_all for 'find'). + includeNonPublicSerialized: If True, includes private fields marked [SerializeField] in component data. + + Action-specific details: + - For 'get_components': + Required: target, search_method + Optional: includeNonPublicSerialized (defaults to True) + Returns all components on the target GameObject with their serialized data. + The search_method parameter determines how to find the target ('by_name', 'by_id', 'by_path'). + + Returns: + Dictionary with operation results ('success', 'message', 'data'). + For 'get_components', the 'data' field contains a dictionary of component names and their serialized properties. + """ + try: + # --- Early check for attempting to modify a prefab asset --- + # ---------------------------------------------------------- + + # Prepare parameters, removing None values + params = { + "action": action, + "target": target, + "searchMethod": search_method, + "name": name, + "tag": tag, + "parent": parent, + "position": position, + "rotation": rotation, + "scale": scale, + "componentsToAdd": components_to_add, + "primitiveType": primitive_type, + "saveAsPrefab": save_as_prefab, + "prefabPath": prefab_path, + "prefabFolder": prefab_folder, + "setActive": set_active, + "layer": layer, + "componentsToRemove": components_to_remove, + "componentProperties": component_properties, + "searchTerm": search_term, + "findAll": find_all, + "searchInChildren": search_in_children, + "searchInactive": search_inactive, + "componentName": component_name, + "includeNonPublicSerialized": includeNonPublicSerialized + } + params = {k: v for k, v in params.items() if v is not None} + + # --- Handle Prefab Path Logic --- + if action == "create" and params.get("saveAsPrefab"): # Check if 'saveAsPrefab' is explicitly True in params + if "prefabPath" not in params: + if "name" not in params or not params["name"]: + return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} + # Use the provided prefab_folder (which has a default) and the name to construct the path + constructed_path = f"{prefab_folder}/{params['name']}.prefab" + # Ensure clean path separators (Unity prefers '/') + params["prefabPath"] = constructed_path.replace("\\", "/") + elif not params["prefabPath"].lower().endswith(".prefab"): + return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} + # Ensure prefab_folder itself isn't sent if prefabPath was constructed or provided + # The C# side only needs the final prefabPath + params.pop("prefab_folder", None) + # -------------------------------- + + # Send the command to Unity via the established connection + # Use the get_unity_connection function to retrieve the active connection instance + # Changed "MANAGE_GAMEOBJECT" to "manage_gameobject" to potentially match Unity expectation + response = get_unity_connection().send_command("manage_gameobject", params) + if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading": + delay_ms = int(response.get("retry_after_ms", 250)) + time.sleep(max(0.0, delay_ms / 1000.0)) + response = get_unity_connection().send_command("manage_gameobject", params) + + # Check if the response indicates success + # If the response is not successful, raise an exception with the error message + if response.get("success"): + return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred during GameObject management.")} + + except Exception as e: + return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py new file mode 100644 index 00000000..c2fdcf2a --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -0,0 +1,52 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any +from unity_connection import get_unity_connection +import time + +def register_manage_scene_tools(mcp: FastMCP): + """Register all scene management tools with the MCP server.""" + + @mcp.tool() + def manage_scene( + ctx: Context, + action: str, + name: str, + path: str, + build_index: int, + ) -> Dict[str, Any]: + """Manages Unity scenes (load, save, create, get hierarchy, etc.). + + Args: + action: Operation (e.g., 'load', 'save', 'create', 'get_hierarchy'). + name: Scene name (no extension) for create/load/save. + path: Asset path for scene operations (default: "Assets/"). + build_index: Build index for load/build settings actions. + # Add other action-specific args as needed (e.g., for hierarchy depth) + + Returns: + Dictionary with results ('success', 'message', 'data'). + """ + try: + params = { + "action": action, + "name": name, + "path": path, + "buildIndex": build_index + } + params = {k: v for k, v in params.items() if v is not None} + + # Send command to Unity (with a single polite retry if reloading) + response = get_unity_connection().send_command("manage_scene", params) + if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading": + delay_ms = int(response.get("retry_after_ms", 250)) + time.sleep(max(0.0, delay_ms / 1000.0)) + response = get_unity_connection().send_command("manage_scene", params) + + # Process response + if response.get("success"): + return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred during scene management.")} + + except Exception as e: + return {"success": False, "message": f"Python error managing scene: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py new file mode 100644 index 00000000..b74fbc81 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -0,0 +1,79 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any +from unity_connection import get_unity_connection +import time +import os +import base64 + +def register_manage_script_tools(mcp: FastMCP): + """Register all script management tools with the MCP server.""" + + @mcp.tool() + def manage_script( + ctx: Context, + action: str, + name: str, + path: str, + contents: str, + script_type: str, + namespace: str + ) -> Dict[str, Any]: + """Manages C# scripts in Unity (create, read, update, delete). + Make reference variables public for easier access in the Unity Editor. + + Args: + action: Operation ('create', 'read', 'update', 'delete'). + name: Script name (no .cs extension). + path: Asset path (default: "Assets/"). + contents: C# code for 'create'/'update'. + script_type: Type hint (e.g., 'MonoBehaviour'). + namespace: Script namespace. + + Returns: + Dictionary with results ('success', 'message', 'data'). + """ + try: + # Prepare parameters for Unity + params = { + "action": action, + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type + } + + # Base64 encode the contents if they exist to avoid JSON escaping issues + if contents is not None: + if action in ['create', 'update']: + # Encode content for safer transmission + params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') + params["contentsEncoded"] = True + else: + params["contents"] = contents + + # Remove None values so they don't get sent as null + params = {k: v for k, v in params.items() if v is not None} + + # Send command to Unity (with single polite retry if reloading) + response = get_unity_connection().send_command("manage_script", params) + if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading": + delay_ms = int(response.get("retry_after_ms", 250)) + time.sleep(max(0.0, delay_ms / 1000.0)) + response = get_unity_connection().send_command("manage_script", params) + + # Process response from Unity + if response.get("success"): + # If the response contains base64 encoded content, decode it + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + + return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred.")} + + except Exception as e: + # Handle Python-side errors (e.g., connection issues) + return {"success": False, "message": f"Python error managing script: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py new file mode 100644 index 00000000..70e0d437 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -0,0 +1,72 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any +from unity_connection import get_unity_connection +import time +import os +import base64 + +def register_manage_shader_tools(mcp: FastMCP): + """Register all shader script management tools with the MCP server.""" + + @mcp.tool() + def manage_shader( + ctx: Context, + action: str, + name: str, + path: str, + contents: str, + ) -> Dict[str, Any]: + """Manages shader scripts in Unity (create, read, update, delete). + + Args: + action: Operation ('create', 'read', 'update', 'delete'). + name: Shader name (no .cs extension). + path: Asset path (default: "Assets/"). + contents: Shader code for 'create'/'update'. + + Returns: + Dictionary with results ('success', 'message', 'data'). + """ + try: + # Prepare parameters for Unity + params = { + "action": action, + "name": name, + "path": path, + } + + # Base64 encode the contents if they exist to avoid JSON escaping issues + if contents is not None: + if action in ['create', 'update']: + # Encode content for safer transmission + params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') + params["contentsEncoded"] = True + else: + params["contents"] = contents + + # Remove None values so they don't get sent as null + params = {k: v for k, v in params.items() if v is not None} + + # Send command to Unity + response = get_unity_connection().send_command("manage_shader", params) + if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading": + delay_ms = int(response.get("retry_after_ms", 250)) + time.sleep(max(0.0, delay_ms / 1000.0)) + response = get_unity_connection().send_command("manage_shader", params) + + # Process response from Unity + if response.get("success"): + # If the response contains base64 encoded content, decode it + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + + return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} + else: + return {"success": False, "message": response.get("error", "An unknown error occurred.")} + + except Exception as e: + # Handle Python-side errors (e.g., connection issues) + return {"success": False, "message": f"Python error managing shader: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py new file mode 100644 index 00000000..94bf2bb9 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -0,0 +1,76 @@ +""" +Defines the read_console tool for accessing Unity Editor console messages. +""" +from typing import List, Dict, Any +import time +from mcp.server.fastmcp import FastMCP, Context +from unity_connection import get_unity_connection + +def register_read_console_tools(mcp: FastMCP): + """Registers the read_console tool with the MCP server.""" + + @mcp.tool() + def read_console( + ctx: Context, + action: str = None, + types: List[str] = None, + count: int = None, + filter_text: str = None, + since_timestamp: str = None, + format: str = None, + include_stacktrace: bool = None + ) -> Dict[str, Any]: + """Gets messages from or clears the Unity Editor console. + + Args: + ctx: The MCP context. + action: Operation ('get' or 'clear'). + types: Message types to get ('error', 'warning', 'log', 'all'). + count: Max messages to return. + filter_text: Text filter for messages. + since_timestamp: Get messages after this timestamp (ISO 8601). + format: Output format ('plain', 'detailed', 'json'). + include_stacktrace: Include stack traces in output. + + Returns: + Dictionary with results. For 'get', includes 'data' (messages). + """ + + # Get the connection instance + bridge = get_unity_connection() + + # Set defaults if values are None + action = action if action is not None else 'get' + types = types if types is not None else ['error', 'warning', 'log'] + format = format if format is not None else 'detailed' + include_stacktrace = include_stacktrace if include_stacktrace is not None else True + + # Normalize action if it's a string + if isinstance(action, str): + action = action.lower() + + # Prepare parameters for the C# handler + params_dict = { + "action": action, + "types": types, + "count": count, + "filterText": filter_text, + "sinceTimestamp": since_timestamp, + "format": format.lower() if isinstance(format, str) else format, + "includeStacktrace": include_stacktrace + } + + # Remove None values unless it's 'count' (as None might mean 'all') + params_dict = {k: v for k, v in params_dict.items() if v is not None or k == 'count'} + + # Add count back if it was None, explicitly sending null might be important for C# logic + if 'count' not in params_dict: + params_dict['count'] = None + + # Forward the command using the bridge's send_command method (with a single polite retry on reload) + resp = bridge.send_command("read_console", params_dict) + if isinstance(resp, dict) and not resp.get("success", True) and resp.get("state") == "reloading": + delay_ms = int(resp.get("retry_after_ms", 250)) + time.sleep(max(0.0, delay_ms / 1000.0)) + resp = bridge.send_command("read_console", params_dict) + return resp \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py new file mode 100644 index 00000000..d874be6a --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -0,0 +1,280 @@ +import socket +import json +import logging +from dataclasses import dataclass +from pathlib import Path +import time +import random +import errno +from typing import Dict, Any +from config import config +from port_discovery import PortDiscovery + +# Configure logging using settings from config +logging.basicConfig( + level=getattr(logging, config.log_level), + format=config.log_format +) +logger = logging.getLogger("unity-mcp-server") + +@dataclass +class UnityConnection: + """Manages the socket connection to the Unity Editor.""" + host: str = config.unity_host + port: int = None # Will be set dynamically + sock: socket.socket = None # Socket for Unity communication + + def __post_init__(self): + """Set port from discovery if not explicitly provided""" + if self.port is None: + self.port = PortDiscovery.discover_unity_port() + + def connect(self) -> bool: + """Establish a connection to the Unity Editor.""" + if self.sock: + return True + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((self.host, self.port)) + logger.info(f"Connected to Unity at {self.host}:{self.port}") + return True + except Exception as e: + logger.error(f"Failed to connect to Unity: {str(e)}") + self.sock = None + return False + + def disconnect(self): + """Close the connection to the Unity Editor.""" + if self.sock: + try: + self.sock.close() + except Exception as e: + logger.error(f"Error disconnecting from Unity: {str(e)}") + finally: + self.sock = None + + def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: + """Receive a complete response from Unity, handling chunked data.""" + chunks = [] + sock.settimeout(config.connection_timeout) # Use timeout from config + try: + while True: + chunk = sock.recv(buffer_size) + if not chunk: + if not chunks: + raise Exception("Connection closed before receiving data") + break + chunks.append(chunk) + + # Process the data received so far + data = b''.join(chunks) + decoded_data = data.decode('utf-8') + + # Check if we've received a complete response + try: + # Special case for ping-pong + if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): + logger.debug("Received ping response") + return data + + # Handle escaped quotes in the content + if '"content":' in decoded_data: + # Find the content field and its value + content_start = decoded_data.find('"content":') + 9 + content_end = decoded_data.rfind('"', content_start) + if content_end > content_start: + # Replace escaped quotes in content with regular quotes + content = decoded_data[content_start:content_end] + content = content.replace('\\"', '"') + decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:] + + # Validate JSON format + json.loads(decoded_data) + + # If we get here, we have valid JSON + logger.info(f"Received complete response ({len(data)} bytes)") + return data + except json.JSONDecodeError: + # We haven't received a complete valid JSON response yet + continue + except Exception as e: + logger.warning(f"Error processing response chunk: {str(e)}") + # Continue reading more chunks as this might not be the complete response + continue + except socket.timeout: + logger.warning("Socket timeout during receive") + raise Exception("Timeout receiving Unity response") + except Exception as e: + logger.error(f"Error during receive: {str(e)}") + raise + + def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: + """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" + # Defensive guard: catch empty/placeholder invocations early + if not command_type: + raise ValueError("MCP call missing command_type") + if params is None: + # Return a fast, structured error that clients can display without hanging + return {"success": False, "error": "MCP call received with no parameters (client placeholder?)"} + attempts = max(config.max_retries, 5) + base_backoff = max(0.5, config.retry_delay) + + def read_status_file() -> dict | None: + try: + status_files = sorted(Path.home().joinpath('.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) + if not status_files: + return None + latest = status_files[0] + with latest.open('r') as f: + return json.load(f) + except Exception: + return None + + last_short_timeout = None + + # Preflight: if Unity reports reloading, return a structured hint so clients can retry politely + try: + status = read_status_file() + if status and (status.get('reloading') or status.get('reason') == 'reloading'): + return { + "success": False, + "state": "reloading", + "retry_after_ms": int(250), + "error": "Unity domain reload in progress", + "message": "Unity is reloading scripts; please retry shortly" + } + except Exception: + pass + + for attempt in range(attempts + 1): + try: + # Ensure connected + if not self.sock: + # During retries use short connect timeout + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(1.0) + self.sock.connect((self.host, self.port)) + # restore steady-state timeout for receive + self.sock.settimeout(config.connection_timeout) + logger.info(f"Connected to Unity at {self.host}:{self.port}") + + # Build payload + if command_type == 'ping': + payload = b'ping' + else: + command = {"type": command_type, "params": params or {}} + payload = json.dumps(command, ensure_ascii=False).encode('utf-8') + + # Send + self.sock.sendall(payload) + + # During retry bursts use a short receive timeout + if attempt > 0 and last_short_timeout is None: + last_short_timeout = self.sock.gettimeout() + self.sock.settimeout(1.0) + response_data = self.receive_full_response(self.sock) + # restore steady-state timeout if changed + if last_short_timeout is not None: + self.sock.settimeout(config.connection_timeout) + last_short_timeout = None + + # Parse + if command_type == 'ping': + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong': + return {"message": "pong"} + raise Exception("Ping unsuccessful") + + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'error': + err = resp.get('error') or resp.get('message', 'Unknown Unity error') + raise Exception(err) + return resp.get('result', {}) + except Exception as e: + logger.warning(f"Unity communication attempt {attempt+1} failed: {e}") + try: + if self.sock: + self.sock.close() + finally: + self.sock = None + + # Re-discover port each time + try: + new_port = PortDiscovery.discover_unity_port() + if new_port != self.port: + logger.info(f"Unity port changed {self.port} -> {new_port}") + self.port = new_port + except Exception as de: + logger.debug(f"Port discovery failed: {de}") + + if attempt < attempts: + # Heartbeat-aware, jittered backoff + status = read_status_file() + # Base exponential backoff + backoff = base_backoff * (2 ** attempt) + # Decorrelated jitter multiplier + jitter = random.uniform(0.1, 0.3) + + # Fast‑retry for transient socket failures + fast_error = isinstance(e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) + if not fast_error: + try: + err_no = getattr(e, 'errno', None) + fast_error = err_no in (errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT) + except Exception: + pass + + # Cap backoff depending on state + if status and status.get('reloading'): + cap = 0.8 + elif fast_error: + cap = 0.25 + else: + cap = 3.0 + + sleep_s = min(cap, jitter * (2 ** attempt)) + time.sleep(sleep_s) + continue + raise + +# Global Unity connection +_unity_connection = None + +def get_unity_connection() -> UnityConnection: + """Retrieve or establish a persistent Unity connection.""" + global _unity_connection + if _unity_connection is not None: + try: + # Try to ping with a short timeout to verify connection + result = _unity_connection.send_command("ping") + # If we get here, the connection is still valid + logger.debug("Reusing existing Unity connection") + return _unity_connection + except Exception as e: + logger.warning(f"Existing connection failed: {str(e)}") + try: + _unity_connection.disconnect() + except: + pass + _unity_connection = None + + # Create a new connection + logger.info("Creating new Unity connection") + _unity_connection = UnityConnection() + if not _unity_connection.connect(): + _unity_connection = None + raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") + + try: + # Verify the new connection works + _unity_connection.send_command("ping") + logger.info("Successfully established new Unity connection") + return _unity_connection + except Exception as e: + logger.error(f"Could not verify new connection: {str(e)}") + try: + _unity_connection.disconnect() + except: + pass + _unity_connection = None + raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") diff --git a/UnityMcpBridge/UnityMcpServer~/src/uv.lock b/UnityMcpBridge/UnityMcpServer~/src/uv.lock new file mode 100644 index 00000000..bc3e54ca --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/uv.lock @@ -0,0 +1,349 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mcp" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/cc/5c5bb19f1a0f8f89a95e25cb608b0b07009e81fd4b031e519335404e1422/mcp-1.4.1.tar.gz", hash = "sha256:b9655d2de6313f9d55a7d1df62b3c3fe27a530100cc85bf23729145b0dba4c7a", size = 154942 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/0e/885f156ade60108e67bf044fada5269da68e29d758a10b0c513f4d85dd76/mcp-1.4.1-py3-none-any.whl", hash = "sha256:a7716b1ec1c054e76f49806f7d96113b99fc1166fc9244c2c6f19867cb75b593", size = 72448 }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sse-starlette" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, +] + +[[package]] +name = "starlette" +version = "0.46.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, +] + +[[package]] +name = "typer" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "unitymcpserver" +version = "2.0.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "mcp", extra = ["cli"] }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.2" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.4.1" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, +] From 24ed3a2e2a094739e15509688ad9f8f3ecfd652b Mon Sep 17 00:00:00 2001 From: dsarno Date: Sat, 9 Aug 2025 15:38:11 -0700 Subject: [PATCH 33/69] docs: update README(s) for Auto-Setup and dev cache path --- README-DEV.md | 16 +++++++++++----- README.md | 8 ++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/README-DEV.md b/README-DEV.md index 398bdab2..71f9679c 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -46,16 +46,22 @@ Restores original files from backup. ## Finding Unity Package Cache Path -Unity package cache is typically located at: +Unity stores Git packages under a version-or-hash folder. Expect something like: ``` -X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0 +X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@ +``` +Example (hash): +``` +X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@272123cfd97e ``` -To find it: +To find it reliably: 1. Open Unity Package Manager 2. Select "Unity MCP" package -3. Right click on the package and "Show in Explorer" -4. Navigate to the path above with your username and version +3. Right click the package and choose "Show in Explorer" +4. That opens the exact cache folder Unity is using for your project + +Note: In recent builds, the Python server sources are also bundled inside the package under `UnityMcpServer~/src`. This is handy for local testing or pointing MCP clients directly at the packaged server. ## Workflow diff --git a/README.md b/README.md index ec23c4f9..fc358c04 100644 --- a/README.md +++ b/README.md @@ -120,15 +120,15 @@ Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installe image -**Option A: Auto-Configure (Recommended for Claude/Cursor/VSC Copilot)** +**Option A: Auto-Setup (Recommended for Claude/Cursor/VSC Copilot)** 1. In Unity, go to `Window > Unity MCP`. -2. Click `Auto Configure` on the IDE you uses. -3. Look for a green status indicator 🟢 and "Connected". *(This attempts to modify the MCP Client\'s config file automatically)*. +2. Click `Auto-Setup`. +3. Look for a green status indicator 🟢 and "Connected ✓". *(This attempts to modify the MCP Client\'s config file automatically)*. **Option B: Manual Configuration** -If Auto-Configure fails or you use a different client: +If Auto-Setup fails or you use a different client: 1. **Find your MCP Client\'s configuration file.** (Check client documentation). * *Claude Example (macOS):* `~/Library/Application Support/Claude/claude_desktop_config.json` From 97614e7277ac761da50d97b9d6c0cba996af96bd Mon Sep 17 00:00:00 2001 From: dsarno Date: Sat, 9 Aug 2025 15:54:55 -0700 Subject: [PATCH 34/69] Update README.md Added new image of MCP Editor window to README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fc358c04..ad19c092 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Unity MCP connects your tools using two components: Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installed in Step 1. -image +UnityMCP-Readme-Image **Option A: Auto-Setup (Recommended for Claude/Cursor/VSC Copilot)** From a40db4813220b862041839a66dc5bd2631f449bb Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 10 Aug 2025 19:45:24 -0700 Subject: [PATCH 35/69] ReadConsole: stable severity classification and filtering across Unity versions - Classify severity via stacktrace/message first (LogError/LogWarning/Exception/Assertion), with safe fallback to mode-bit mapping - Fix error/warning/log mapping; treat Exception/Assert as errors for filtering - Return the current console buffer reliably and remove debug spam - No changes outside ReadConsole behavior --- UnityMcpBridge/Editor/Tools/ReadConsole.cs | 127 ++++++++++++++------- 1 file changed, 86 insertions(+), 41 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ReadConsole.cs b/UnityMcpBridge/Editor/Tools/ReadConsole.cs index e3470cf3..eca81b8d 100644 --- a/UnityMcpBridge/Editor/Tools/ReadConsole.cs +++ b/UnityMcpBridge/Editor/Tools/ReadConsole.cs @@ -16,6 +16,8 @@ namespace UnityMcpBridge.Editor.Tools /// public static class ReadConsole { + // (Calibration removed) + // Reflection members for accessing internal LogEntry data // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection private static MethodInfo _startGettingEntriesMethod; @@ -41,6 +43,8 @@ static ReadConsole() ); if (logEntriesType == null) throw new Exception("Could not find internal type UnityEditor.LogEntries"); + + // Include NonPublic binding flags as internal APIs might change accessibility BindingFlags staticFlags = @@ -100,6 +104,9 @@ static ReadConsole() _instanceIdField = logEntryType.GetField("instanceID", instanceFlags); if (_instanceIdField == null) throw new Exception("Failed to reflect LogEntry.instanceID"); + + // (Calibration removed) + } catch (Exception e) { @@ -251,16 +258,44 @@ bool includeStacktrace // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance); if (string.IsNullOrEmpty(message)) + { continue; // Skip empty messages + } + + // (Calibration removed) // --- Filtering --- - // Filter by type - LogType currentType = GetLogTypeFromMode(mode); - if (!types.Contains(currentType.ToString().ToLowerInvariant())) + // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed + LogType unityType = InferTypeFromMessage(message); + if (unityType == LogType.Log) { - continue; + unityType = GetLogTypeFromMode(mode); } + bool want; + if (types.Contains("all")) + { + want = true; + } + else + { + // Treat Exception/Assert as errors for filtering convenience + if (unityType == LogType.Exception) + { + want = types.Contains("error") || types.Contains("exception"); + } + else if (unityType == LogType.Assert) + { + want = types.Contains("error") || types.Contains("assert"); + } + else + { + want = types.Contains(unityType.ToString().ToLowerInvariant()); + } + } + + if (!want) continue; + // Filter by text (case-insensitive) if ( !string.IsNullOrEmpty(filterText) @@ -294,7 +329,7 @@ bool includeStacktrace default: formattedEntry = new { - type = currentType.ToString(), + type = unityType.ToString(), message = messageOnly, file = file, line = line, @@ -350,15 +385,12 @@ bool includeStacktrace // --- Internal Helpers --- - // Mapping from LogEntry.mode bits to LogType enum - // Based on decompiled UnityEditor code or common patterns. Precise bits might change between Unity versions. - // See comments below for LogEntry mode bits exploration. - // Note: This mapping is simplified and might not cover all edge cases or future Unity versions perfectly. + // Mapping bits from LogEntry.mode. These may vary by Unity version. private const int ModeBitError = 1 << 0; private const int ModeBitAssert = 1 << 1; private const int ModeBitWarning = 1 << 2; private const int ModeBitLog = 1 << 3; - private const int ModeBitException = 1 << 4; // Often combined with Error bits + private const int ModeBitException = 1 << 4; // often combined with Error bits private const int ModeBitScriptingError = 1 << 9; private const int ModeBitScriptingWarning = 1 << 10; private const int ModeBitScriptingLog = 1 << 11; @@ -367,46 +399,59 @@ bool includeStacktrace private static LogType GetLogTypeFromMode(int mode) { - // First, determine the type based on the original logic (most severe first) - LogType initialType; - if ( - ( - mode - & ( - ModeBitError - | ModeBitScriptingError - | ModeBitException - | ModeBitScriptingException - ) - ) != 0 - ) - { - initialType = LogType.Error; - } - else if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) - { - initialType = LogType.Assert; - } - else if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) - { - initialType = LogType.Warning; - } - else - { - initialType = LogType.Log; - } + // Preserve Unity's real type (no remapping); bits may vary by version + if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception; + if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error; + if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert; + if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning; + return LogType.Log; + } + + // (Calibration helpers removed) + + /// + /// Classifies severity using message/stacktrace content. Works across Unity versions. + /// + private static LogType InferTypeFromMessage(string fullMessage) + { + if (string.IsNullOrEmpty(fullMessage)) return LogType.Log; + + // Fast path: look for explicit Debug API names in the appended stack trace + // e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning" + if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Error; + if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Warning; + + // Exceptions often include the word "Exception" in the first lines + if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Exception; - // Apply the observed "one level lower" correction - switch (initialType) + // Unity assertions + if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Assert; + + return LogType.Log; + } + + /// + /// Applies the "one level lower" remapping for filtering, like the old version. + /// This ensures compatibility with the filtering logic that expects remapped types. + /// + private static LogType GetRemappedTypeForFiltering(LogType unityType) + { + switch (unityType) { case LogType.Error: return LogType.Warning; // Error becomes Warning case LogType.Warning: return LogType.Log; // Warning becomes Log case LogType.Assert: - return LogType.Assert; // Assert remains Assert (no lower level defined) + return LogType.Assert; // Assert remains Assert case LogType.Log: return LogType.Log; // Log remains Log + case LogType.Exception: + return LogType.Warning; // Exception becomes Warning default: return LogType.Log; // Default fallback } From dc6171dfe653aeb91e78f947100016cf835ad218 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 10 Aug 2025 20:12:45 -0700 Subject: [PATCH 36/69] ReadConsole: lock Debug.Log classification to Log; avoid bit-based fallback when stacktrace shows Debug:Log - Detect explicit Debug.Log in stacktrace (UnityEngine.Debug:Log) - Do not downgrade/upgrade to Warning via mode bits for editor-originated logs - Keeps informational setup lines (e.g., MCP registration, bridge start) as Log --- UnityMcpBridge/Editor/Tools/ReadConsole.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Tools/ReadConsole.cs b/UnityMcpBridge/Editor/Tools/ReadConsole.cs index eca81b8d..12350d2e 100644 --- a/UnityMcpBridge/Editor/Tools/ReadConsole.cs +++ b/UnityMcpBridge/Editor/Tools/ReadConsole.cs @@ -267,7 +267,8 @@ bool includeStacktrace // --- Filtering --- // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed LogType unityType = InferTypeFromMessage(message); - if (unityType == LogType.Log) + bool isExplicitDebugLog = IsExplicitDebugLog(message); + if (!isExplicitDebugLog && unityType == LogType.Log) { unityType = GetLogTypeFromMode(mode); } @@ -422,6 +423,8 @@ private static LogType InferTypeFromMessage(string fullMessage) return LogType.Error; if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Warning; + if (IsExplicitDebugLog(fullMessage)) + return LogType.Log; // Exceptions often include the word "Exception" in the first lines if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0) @@ -434,6 +437,15 @@ private static LogType InferTypeFromMessage(string fullMessage) return LogType.Log; } + private static bool IsExplicitDebugLog(string fullMessage) + { + // Detect explicit Debug.Log in the stacktrace/message to lock type to Log + if (string.IsNullOrEmpty(fullMessage)) return false; + if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; + return false; + } + /// /// Applies the "one level lower" remapping for filtering, like the old version. /// This ensures compatibility with the filtering logic that expects remapped types. From 1938756844a21025d7ee7aabbea0a3c54044691c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sun, 10 Aug 2025 22:49:24 -0700 Subject: [PATCH 37/69] server: centralize reload-aware retries and single-source retry_after_ms via config; increase default retry window (40 x 250ms); preserve structured reloading failures --- UnityMcpBridge/UnityMcpServer~/src/config.py | 5 ++ .../src/tools/execute_menu_item.py | 18 ++----- .../UnityMcpServer~/src/tools/manage_asset.py | 24 ++------- .../src/tools/manage_editor.py | 18 +++---- .../src/tools/manage_gameobject.py | 18 +++---- .../UnityMcpServer~/src/tools/manage_scene.py | 18 +++---- .../src/tools/manage_script.py | 16 +++--- .../src/tools/manage_shader.py | 16 +++--- .../UnityMcpServer~/src/tools/read_console.py | 13 ++--- .../UnityMcpServer~/src/unity_connection.py | 53 ++++++++++++++++++- 10 files changed, 104 insertions(+), 95 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/config.py b/UnityMcpBridge/UnityMcpServer~/src/config.py index d3a203c7..6100b2aa 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/config.py +++ b/UnityMcpBridge/UnityMcpServer~/src/config.py @@ -25,6 +25,11 @@ class ServerConfig: # Server settings max_retries: int = 10 retry_delay: float = 0.25 + # Backoff hint returned to clients when Unity is reloading (milliseconds) + reload_retry_ms: int = 250 + # Number of polite retries when Unity reports reloading + # 40 × 250ms ≈ 10s default window + reload_max_retries: int = 40 # Create a global config instance config = ServerConfig() \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py index 10af5292..a448465d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/execute_menu_item.py @@ -3,7 +3,8 @@ """ from typing import Dict, Any from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection # Import unity_connection module +from unity_connection import get_unity_connection, send_command_with_retry # Import retry helper +from config import config import time def register_execute_menu_item_tools(mcp: FastMCP): @@ -43,15 +44,6 @@ async def execute_menu_item( if "parameters" not in params_dict: params_dict["parameters"] = {} # Ensure parameters dict exists - # Get Unity connection and send the command - # We use the unity_connection module to communicate with Unity - unity_conn = get_unity_connection() - - # Send command to the ExecuteMenuItem C# handler - # The command type should match what the Unity side expects - resp = unity_conn.send_command("execute_menu_item", params_dict) - if isinstance(resp, dict) and not resp.get("success", True) and resp.get("state") == "reloading": - delay_ms = int(resp.get("retry_after_ms", 250)) - time.sleep(max(0.0, delay_ms / 1000.0)) - resp = unity_conn.send_command("execute_menu_item", params_dict) - return resp \ No newline at end of file + # 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)} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index 53c14707..19ac0c2e 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -5,7 +5,8 @@ from typing import Dict, Any from mcp.server.fastmcp import FastMCP, Context # from ..unity_connection import get_unity_connection # Original line that caused error -from unity_connection import get_unity_connection # Use absolute import relative to Python dir +from unity_connection import get_unity_connection, async_send_command_with_retry # Use centralized retry helper +from config import config import time def register_manage_asset_tools(mcp: FastMCP): @@ -72,22 +73,7 @@ async def manage_asset( # Get the Unity connection instance connection = get_unity_connection() - # Run the synchronous send_command in the default executor (thread pool) - # This prevents blocking the main async event loop. - result = await loop.run_in_executor( - None, # Use default executor - connection.send_command, # The function to call - "manage_asset", # First argument for send_command - params_dict # Second argument for send_command - ) - if isinstance(result, dict) and not result.get("success", True) and result.get("state") == "reloading": - delay_ms = int(result.get("retry_after_ms", 250)) - await asyncio.sleep(max(0.0, delay_ms / 1000.0)) - result = await loop.run_in_executor( - None, - connection.send_command, - "manage_asset", - params_dict - ) + # Use centralized async retry helper to avoid blocking the event loop + result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) # Return the result obtained from Unity - return result \ No newline at end of file + return result if isinstance(result, dict) else {"success": False, "message": str(result)} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py index 9c347d06..8ff7378f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -1,7 +1,8 @@ from mcp.server.fastmcp import FastMCP, Context import time from typing import Dict, Any -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config def register_manage_editor_tools(mcp: FastMCP): """Register all editor management tools with the MCP server.""" @@ -41,18 +42,13 @@ def manage_editor( } params = {k: v for k, v in params.items() if v is not None} - # Send command to Unity (with a single polite retry if reloading) - response = get_unity_connection().send_command("manage_editor", params) - if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading": - delay_ms = int(response.get("retry_after_ms", 250)) - time.sleep(max(0.0, delay_ms / 1000.0)) - response = get_unity_connection().send_command("manage_editor", params) + # Send command using centralized retry helper + response = send_command_with_retry("manage_editor", params) - # Process response - if response.get("success"): + # Preserve structured failure data; unwrap success into a friendlier shape + if isinstance(response, dict) and response.get("success"): return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during editor management.")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: return {"success": False, "message": f"Python error managing editor: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py index 37871486..cbe29a31 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -1,6 +1,7 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any, List -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config import time def register_manage_gameobject_tools(mcp: FastMCP): @@ -123,21 +124,14 @@ def manage_gameobject( params.pop("prefab_folder", None) # -------------------------------- - # Send the command to Unity via the established connection - # Use the get_unity_connection function to retrieve the active connection instance - # Changed "MANAGE_GAMEOBJECT" to "manage_gameobject" to potentially match Unity expectation - response = get_unity_connection().send_command("manage_gameobject", params) - if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading": - delay_ms = int(response.get("retry_after_ms", 250)) - time.sleep(max(0.0, delay_ms / 1000.0)) - response = get_unity_connection().send_command("manage_gameobject", params) + # Use centralized retry helper + response = send_command_with_retry("manage_gameobject", params) # Check if the response indicates success # If the response is not successful, raise an exception with the error message - if response.get("success"): + if isinstance(response, dict) and response.get("success"): return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during GameObject management.")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index c2fdcf2a..c2257ef4 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -1,6 +1,7 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config import time def register_manage_scene_tools(mcp: FastMCP): @@ -35,18 +36,13 @@ def manage_scene( } params = {k: v for k, v in params.items() if v is not None} - # Send command to Unity (with a single polite retry if reloading) - response = get_unity_connection().send_command("manage_scene", params) - if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading": - delay_ms = int(response.get("retry_after_ms", 250)) - time.sleep(max(0.0, delay_ms / 1000.0)) - response = get_unity_connection().send_command("manage_scene", params) + # Use centralized retry helper + response = send_command_with_retry("manage_scene", params) - # Process response - if response.get("success"): + # Preserve structured failure data; unwrap success into a friendlier shape + if isinstance(response, dict) and response.get("success"): return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred during scene management.")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: return {"success": False, "message": f"Python error managing scene: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index b74fbc81..a41fb85c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -1,6 +1,7 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config import time import os import base64 @@ -54,15 +55,11 @@ def manage_script( # Remove None values so they don't get sent as null params = {k: v for k, v in params.items() if v is not None} - # Send command to Unity (with single polite retry if reloading) - response = get_unity_connection().send_command("manage_script", params) - if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading": - delay_ms = int(response.get("retry_after_ms", 250)) - time.sleep(max(0.0, delay_ms / 1000.0)) - response = get_unity_connection().send_command("manage_script", params) + # Send command via centralized retry helper + response = send_command_with_retry("manage_script", params) # Process response from Unity - if response.get("success"): + if isinstance(response, dict) and response.get("success"): # If the response contains base64 encoded content, decode it if response.get("data", {}).get("contentsEncoded"): decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') @@ -71,8 +68,7 @@ def manage_script( del response["data"]["contentsEncoded"] return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred.")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: # Handle Python-side errors (e.g., connection issues) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py index 70e0d437..8ddb6c7c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -1,6 +1,7 @@ from mcp.server.fastmcp import FastMCP, Context from typing import Dict, Any -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config import time import os import base64 @@ -47,15 +48,11 @@ def manage_shader( # Remove None values so they don't get sent as null params = {k: v for k, v in params.items() if v is not None} - # Send command to Unity - response = get_unity_connection().send_command("manage_shader", params) - if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading": - delay_ms = int(response.get("retry_after_ms", 250)) - time.sleep(max(0.0, delay_ms / 1000.0)) - response = get_unity_connection().send_command("manage_shader", params) + # Send command via centralized retry helper + response = send_command_with_retry("manage_shader", params) # Process response from Unity - if response.get("success"): + if isinstance(response, dict) and response.get("success"): # If the response contains base64 encoded content, decode it if response.get("data", {}).get("contentsEncoded"): decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') @@ -64,8 +61,7 @@ def manage_shader( del response["data"]["contentsEncoded"] return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - else: - return {"success": False, "message": response.get("error", "An unknown error occurred.")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: # Handle Python-side errors (e.g., connection issues) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index 94bf2bb9..098951c6 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -4,7 +4,8 @@ from typing import List, Dict, Any import time from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection +from unity_connection import get_unity_connection, send_command_with_retry +from config import config def register_read_console_tools(mcp: FastMCP): """Registers the read_console tool with the MCP server.""" @@ -67,10 +68,6 @@ def read_console( if 'count' not in params_dict: params_dict['count'] = None - # Forward the command using the bridge's send_command method (with a single polite retry on reload) - resp = bridge.send_command("read_console", params_dict) - if isinstance(resp, dict) and not resp.get("success", True) and resp.get("state") == "reloading": - delay_ms = int(resp.get("retry_after_ms", 250)) - time.sleep(max(0.0, delay_ms / 1000.0)) - resp = bridge.send_command("read_console", params_dict) - return resp \ No newline at end of file + # Use centralized retry helper + resp = send_command_with_retry("read_console", params_dict) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index d874be6a..9bad736d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -139,7 +139,7 @@ def read_status_file() -> dict | None: return { "success": False, "state": "reloading", - "retry_after_ms": int(250), + "retry_after_ms": int(config.reload_retry_ms), "error": "Unity domain reload in progress", "message": "Unity is reloading scripts; please retry shortly" } @@ -278,3 +278,54 @@ def get_unity_connection() -> UnityConnection: pass _unity_connection = None raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") + + +# ----------------------------- +# Centralized retry helpers +# ----------------------------- + +def _is_reloading_response(resp: dict) -> bool: + """Return True if the Unity response indicates the editor is reloading.""" + if not isinstance(resp, dict): + return False + if resp.get("state") == "reloading": + return True + message_text = (resp.get("message") or resp.get("error") or "").lower() + return "reload" in message_text + + +def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: + """Send a command via the shared connection, waiting politely through Unity reloads. + + Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the + structured failure if retries are exhausted. + """ + conn = get_unity_connection() + if max_retries is None: + max_retries = getattr(config, "reload_max_retries", 40) + if retry_ms is None: + retry_ms = getattr(config, "reload_retry_ms", 250) + + response = conn.send_command(command_type, params) + retries = 0 + while _is_reloading_response(response) and retries < max_retries: + delay_ms = int(response.get("retry_after_ms", retry_ms)) if isinstance(response, dict) else retry_ms + time.sleep(max(0.0, delay_ms / 1000.0)) + retries += 1 + response = conn.send_command(command_type, params) + return response + + +async def async_send_command_with_retry(command_type: str, params: Dict[str, Any], *, loop=None, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: + """Async wrapper that runs the blocking retry helper in a thread pool.""" + try: + import asyncio # local import to avoid mandatory asyncio dependency for sync callers + if loop is None: + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, + lambda: send_command_with_retry(command_type, params, max_retries=max_retries, retry_ms=retry_ms), + ) + except Exception as e: + # Return a structured error dict for consistency with other responses + return {"success": False, "error": f"Python async retry helper failed: {str(e)}"} From a506f9b346756bd0640f6d06eda270dcb6a3b808 Mon Sep 17 00:00:00 2001 From: dsarno Date: Mon, 11 Aug 2025 12:30:36 -0700 Subject: [PATCH 38/69] Update package.json version to 2.0.0 --- UnityMcpBridge/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index bbb5994c..0e3ccdfc 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "1.0.0", + "version": "2.0.0", "displayName": "Unity MCP Bridge", "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", "unity": "2020.3", From 46f616df909ceff470d9a87a1b5a942c7ab3c6c1 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 11 Aug 2025 16:52:42 -0700 Subject: [PATCH 39/69] read_console: correct compiler diagnostic categorization (CSxxxx), preserve Debug.Log as Log without mode fallback, add explicit Debug.Log detection helper --- UnityMcpBridge/Editor/Tools/ReadConsole.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ReadConsole.cs b/UnityMcpBridge/Editor/Tools/ReadConsole.cs index 12350d2e..cbeb4e43 100644 --- a/UnityMcpBridge/Editor/Tools/ReadConsole.cs +++ b/UnityMcpBridge/Editor/Tools/ReadConsole.cs @@ -267,8 +267,8 @@ bool includeStacktrace // --- Filtering --- // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed LogType unityType = InferTypeFromMessage(message); - bool isExplicitDebugLog = IsExplicitDebugLog(message); - if (!isExplicitDebugLog && unityType == LogType.Log) + bool isExplicitDebug = IsExplicitDebugLog(message); + if (!isExplicitDebug && unityType == LogType.Log) { unityType = GetLogTypeFromMode(mode); } @@ -423,10 +423,16 @@ private static LogType InferTypeFromMessage(string fullMessage) return LogType.Error; if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Warning; - if (IsExplicitDebugLog(fullMessage)) - return LogType.Log; - // Exceptions often include the word "Exception" in the first lines + // Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx" + if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0 + || fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Warning; + if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0 + || fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Error; + + // Exceptions (avoid misclassifying compiler diagnostics) if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Exception; @@ -439,7 +445,6 @@ private static LogType InferTypeFromMessage(string fullMessage) private static bool IsExplicitDebugLog(string fullMessage) { - // Detect explicit Debug.Log in the stacktrace/message to lock type to Log if (string.IsNullOrEmpty(fullMessage)) return false; if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; From ae87e3f3b2c6918c0d2dc1c7c5bee296f9a54d17 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 11 Aug 2025 17:26:51 -0700 Subject: [PATCH 40/69] read_console: remove dead types.Contains("all") branch; compute want directly from unityType (Exception/Assert treated as errors) --- UnityMcpBridge/Editor/Tools/ReadConsole.cs | 23 ++++++++-------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ReadConsole.cs b/UnityMcpBridge/Editor/Tools/ReadConsole.cs index cbeb4e43..4bd40090 100644 --- a/UnityMcpBridge/Editor/Tools/ReadConsole.cs +++ b/UnityMcpBridge/Editor/Tools/ReadConsole.cs @@ -274,25 +274,18 @@ bool includeStacktrace } bool want; - if (types.Contains("all")) + // Treat Exception/Assert as errors for filtering convenience + if (unityType == LogType.Exception) + { + want = types.Contains("error") || types.Contains("exception"); + } + else if (unityType == LogType.Assert) { - want = true; + want = types.Contains("error") || types.Contains("assert"); } else { - // Treat Exception/Assert as errors for filtering convenience - if (unityType == LogType.Exception) - { - want = types.Contains("error") || types.Contains("exception"); - } - else if (unityType == LogType.Assert) - { - want = types.Contains("error") || types.Contains("assert"); - } - else - { - want = types.Contains(unityType.ToString().ToLowerInvariant()); - } + want = types.Contains(unityType.ToString().ToLowerInvariant()); } if (!want) continue; From 8984ab95bc8a1e3aa8cfe21c898ab85d681bd167 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 12 Aug 2025 08:32:51 -0700 Subject: [PATCH 41/69] feat: local-only package resolution + Claude CLI resolver; quieter install logs; guarded auto-registration --- UnityMcpBridge/Editor/Helpers/ExecPath.cs | 175 ++++++++++ .../Editor/Helpers/ServerInstaller.cs | 112 +------ .../Editor/Helpers/ServerPathResolver.cs | 151 +++++++++ .../Editor/Windows/UnityMcpEditorWindow.cs | 302 +++--------------- 4 files changed, 387 insertions(+), 353 deletions(-) create mode 100644 UnityMcpBridge/Editor/Helpers/ExecPath.cs create mode 100644 UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs diff --git a/UnityMcpBridge/Editor/Helpers/ExecPath.cs b/UnityMcpBridge/Editor/Helpers/ExecPath.cs new file mode 100644 index 00000000..1848dcab --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/ExecPath.cs @@ -0,0 +1,175 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using UnityEditor; + +namespace UnityMcpBridge.Editor.Helpers +{ + internal static class ExecPath + { + private const string PrefClaude = "UnityMCP.ClaudeCliPath"; + + // Resolve Claude CLI absolute path. Pref → env → common locations → PATH. + internal static string ResolveClaude() + { + try + { + string pref = EditorPrefs.GetString(PrefClaude, string.Empty); + if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref; + } + catch { } + + string env = Environment.GetEnvironmentVariable("CLAUDE_CLI"); + if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + Path.Combine(home, ".local", "bin", "claude"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { +#if UNITY_EDITOR_WINDOWS + // Common npm global locations + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; + string[] candidates = + { + Path.Combine(appData, "npm", "claude.cmd"), + Path.Combine(localAppData, "npm", "claude.cmd"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude"); + if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; +#endif + return null; + } + + // Linux + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/usr/local/bin/claude", + "/usr/bin/claude", + Path.Combine(home, ".local", "bin", "claude"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("claude", "/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + } + + // Use existing UV resolver; returns absolute path or null. + internal static string ResolveUv() + { + return ServerInstaller.FindUvPath(); + } + + internal static bool TryRun( + string file, + string args, + string workingDir, + out string stdout, + out string stderr, + int timeoutMs = 15000, + string extraPathPrepend = null) + { + stdout = string.Empty; + stderr = string.Empty; + try + { + var psi = new ProcessStartInfo + { + FileName = file, + Arguments = args, + WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + if (!string.IsNullOrEmpty(extraPathPrepend)) + { + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.Environment["PATH"] = string.IsNullOrEmpty(currentPath) + ? extraPathPrepend + : (extraPathPrepend + System.IO.Path.PathSeparator + currentPath); + } + using var p = Process.Start(psi); + if (p == null) return false; + stdout = p.StandardOutput.ReadToEnd(); + stderr = p.StandardError.ReadToEnd(); + if (!p.WaitForExit(timeoutMs)) { try { p.Kill(); } catch { } return false; } + return p.ExitCode == 0; + } + catch + { + return false; + } + } + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + private static string Which(string exe, string prependPath) + { + try + { + var psi = new ProcessStartInfo("/usr/bin/which", exe) + { + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.Environment["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); + using var p = Process.Start(psi); + string output = p?.StandardOutput.ReadToEnd().Trim(); + p?.WaitForExit(1500); + return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null; + } + catch { return null; } + } +#endif + +#if UNITY_EDITOR_WINDOWS + private static string Where(string exe) + { + try + { + var psi = new ProcessStartInfo("where", exe) + { + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + using var p = Process.Start(psi); + string first = p?.StandardOutput.ReadToEnd() + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + p?.WaitForExit(1500); + return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; + } + catch { return null; } + } +#endif + } +} + + diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 32a30701..0c3138bf 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Runtime.InteropServices; +using System.Reflection; using UnityEditor; using UnityEngine; @@ -42,6 +43,16 @@ public static void EnsureServerInstalled() } catch (Exception ex) { + // If a usable server is already present (installed or embedded), don't fail hard—just warn. + bool hasInstalled = false; + try { hasInstalled = File.Exists(Path.Combine(GetServerPath(), "server.py")); } catch { } + + if (hasInstalled || TryGetEmbeddedServerSource(out _)) + { + Debug.LogWarning($"UnityMCP: Using existing server; skipped install. Details: {ex.Message}"); + return; + } + Debug.LogError($"Failed to ensure server installation: {ex.Message}"); } } @@ -114,104 +125,7 @@ private static bool IsServerInstalled(string location) /// private static bool TryGetEmbeddedServerSource(out string srcPath) { - // 1) Development mode: common repo layouts - try - { - string projectRoot = Path.GetDirectoryName(Application.dataPath); - string[] devCandidates = - { - Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), - Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), - }; - foreach (string candidate in devCandidates) - { - string full = Path.GetFullPath(candidate); - if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) - { - srcPath = full; - return true; - } - } - } - catch { /* ignore */ } - - // 2) Installed package: resolve via Package Manager - // 2) Installed package: resolve via Package Manager (support new + legacy IDs, warn on legacy) -try -{ - var list = UnityEditor.PackageManager.Client.List(); - while (!list.IsCompleted) { } - if (list.Status == UnityEditor.PackageManager.StatusCode.Success) - { - const string CurrentId = "com.coplaydev.unity-mcp"; - const string LegacyId = "com.justinpbarnett.unity-mcp"; - - foreach (var pkg in list.Result) - { - if (pkg.name == CurrentId || pkg.name == LegacyId) - { - if (pkg.name == LegacyId) - { - Debug.LogWarning( - "UnityMCP: Detected legacy package id 'com.justinpbarnett.unity-mcp'. " + - "Please update Packages/manifest.json to 'com.coplaydev.unity-mcp' to avoid future breakage." - ); - } - - string packagePath = pkg.resolvedPath; // e.g., Library/PackageCache/... or local path - - // Preferred: tilde folder embedded alongside Editor/Runtime within the package - string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); - if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) - { - srcPath = embeddedTilde; - return true; - } - - // Fallback: legacy non-tilde folder name inside the package - string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); - if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) - { - srcPath = embedded; - return true; - } - - // Legacy: sibling of the package folder (dev-linked). Only valid when present on disk. - string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); - if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) - { - srcPath = sibling; - return true; - } - } - } - } -} - - catch { /* ignore */ } - - // 3) Fallback to previous common install locations - try - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - string[] candidates = - { - Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), - Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), - }; - foreach (string candidate in candidates) - { - if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) - { - srcPath = candidate; - return true; - } - } - } - catch { /* ignore */ } - - srcPath = null; - return false; + return ServerPathResolver.TryFindEmbeddedServerSource(out srcPath); } private static void CopyDirectoryRecursive(string sourceDir, string destinationDir) @@ -313,7 +227,7 @@ public static bool RepairPythonEnvironment() } } - private static string FindUvPath() + internal static string FindUvPath() { // Allow user override via EditorPrefs try diff --git a/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs b/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs new file mode 100644 index 00000000..aa79fd0e --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs @@ -0,0 +1,151 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace UnityMcpBridge.Editor.Helpers +{ + public static class ServerPathResolver + { + /// + /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package + /// or common development locations. Returns true if found and sets srcPath to the folder + /// containing server.py. + /// + public static bool TryFindEmbeddedServerSource(out string srcPath, bool warnOnLegacyPackageId = true) + { + // 1) Repo development layouts commonly used alongside this package + try + { + string projectRoot = Path.GetDirectoryName(Application.dataPath); + string[] devCandidates = + { + Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), + Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), + }; + foreach (string candidate in devCandidates) + { + string full = Path.GetFullPath(candidate); + if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) + { + srcPath = full; + return true; + } + } + } + catch { /* ignore */ } + + // 2) Resolve via local package info (no network). Fall back to Client.List on older editors. + try + { +#if UNITY_2021_2_OR_NEWER + // Primary: the package that owns this assembly + var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly); + if (owner != null) + { + if (TryResolveWithinPackage(owner, out srcPath, warnOnLegacyPackageId)) + { + return true; + } + } + + // Secondary: scan all registered packages locally + foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages()) + { + if (TryResolveWithinPackage(p, out srcPath, warnOnLegacyPackageId)) + { + return true; + } + } +#else + // Older Unity versions: use Package Manager Client.List as a fallback + var list = UnityEditor.PackageManager.Client.List(); + while (!list.IsCompleted) { } + if (list.Status == UnityEditor.PackageManager.StatusCode.Success) + { + foreach (var pkg in list.Result) + { + if (TryResolveWithinPackage(pkg, out srcPath, warnOnLegacyPackageId)) + { + return true; + } + } + } +#endif + } + catch { /* ignore */ } + + // 3) Fallback to previous common install locations + try + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), + Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), + }; + foreach (string candidate in candidates) + { + if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) + { + srcPath = candidate; + return true; + } + } + } + catch { /* ignore */ } + + srcPath = null; + return false; + } + + private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageInfo p, out string srcPath, bool warnOnLegacyPackageId) + { + const string CurrentId = "com.coplaydev.unity-mcp"; + const string LegacyId = "com.justinpbarnett.unity-mcp"; + + srcPath = null; + if (p == null || (p.name != CurrentId && p.name != LegacyId)) + { + return false; + } + + if (warnOnLegacyPackageId && p.name == LegacyId) + { + Debug.LogWarning( + "UnityMCP: Detected legacy package id 'com.justinpbarnett.unity-mcp'. " + + "Please update Packages/manifest.json to 'com.coplaydev.unity-mcp' to avoid future breakage."); + } + + string packagePath = p.resolvedPath; + + // Preferred tilde folder (embedded but excluded from import) + string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); + if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) + { + srcPath = embeddedTilde; + return true; + } + + // Legacy non-tilde folder + string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); + if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) + { + srcPath = embedded; + return true; + } + + // Dev-linked sibling of the package folder + string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); + if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) + { + srcPath = sibling; + return true; + } + + return false; + } + } +} + + diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 859ce15c..f86acd1c 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -66,8 +66,8 @@ private void OnEnable() // Load validation level setting LoadValidationLevelSetting(); - // First-run auto-setup (register client(s) and ensure bridge is listening) - if (autoRegisterEnabled) + // First-run auto-setup only if Claude CLI is available + if (autoRegisterEnabled && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) { AutoFirstRunSetup(); } @@ -492,7 +492,8 @@ private void AutoFirstRunSetup() { if (client.mcpType == McpTypes.ClaudeCode) { - if (!IsClaudeConfigured()) + // Only attempt if Claude CLI is present + if (!IsClaudeConfigured() && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) { RegisterWithClaudeCode(pythonDir); anyRegistered = true; @@ -987,65 +988,10 @@ private string FindPackagePythonDirectory() } } - // Try to find the package using Package Manager API - UnityEditor.PackageManager.Requests.ListRequest request = - UnityEditor.PackageManager.Client.List(); - while (!request.IsCompleted) { } // Wait for the request to complete - - if (request.Status == UnityEditor.PackageManager.StatusCode.Success) - { - foreach (UnityEditor.PackageManager.PackageInfo package in request.Result) - { - if (package.name == "com.coplaydev.unity-mcp") - { - string packagePath = package.resolvedPath; - - // Preferred: check for tilde folder inside package - string packagedTildeDir = Path.Combine(packagePath, "UnityMcpServer~", "src"); - if (Directory.Exists(packagedTildeDir) && File.Exists(Path.Combine(packagedTildeDir, "server.py"))) - { - return packagedTildeDir; - } - - // Fallback: legacy local package structure (UnityMcpServer/src) - string localPythonDir = Path.Combine(Path.GetDirectoryName(packagePath), "UnityMcpServer", "src"); - if (Directory.Exists(localPythonDir) && File.Exists(Path.Combine(localPythonDir, "server.py"))) - { - return localPythonDir; - } - - // Check for old structure (Python subdirectory) - string potentialPythonDir = Path.Combine(packagePath, "Python"); - if (Directory.Exists(potentialPythonDir) && File.Exists(Path.Combine(potentialPythonDir, "server.py"))) - { - return potentialPythonDir; - } - } - } - } - else if (request.Error != null) - { - UnityEngine.Debug.LogError("Failed to list packages: " + request.Error.message); - } - - // If not found via Package Manager, try manual approaches - // Check for local development structure - string[] possibleDirs = - { - // Check in user's home directory (common installation location) - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "unity-mcp", "UnityMcpServer", "src"), - // Check in Applications folder (macOS/Linux common location) - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications", "UnityMCP", "UnityMcpServer", "src"), - // Legacy Python folder structure - Path.GetFullPath(Path.Combine(Application.dataPath, "unity-mcp", "Python")), - }; - - foreach (string dir in possibleDirs) + // Resolve via shared helper (handles local registry and older fallback) + if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) { - if (Directory.Exists(dir) && File.Exists(Path.Combine(dir, "server.py"))) - { - return dir; - } + return embedded; } // If still not found, return the placeholder path @@ -1358,218 +1304,66 @@ private void CheckMcpConfiguration(McpClient mcpClient) private void RegisterWithClaudeCode(string pythonDir) { - string command; - string args; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - command = FindClaudeCommand(); - - if (string.IsNullOrEmpty(command)) - { - UnityEngine.Debug.LogError("Claude CLI not found. Please ensure Claude Code is installed and accessible."); - return; - } - - // Try to find uv.exe in common locations - string uvPath = FindUvPath(); - - if (string.IsNullOrEmpty(uvPath)) - { - // Fallback to expecting uv in PATH - args = $"mcp add UnityMCP -- uv --directory \"{pythonDir}\" run server.py"; - } - else - { - args = $"mcp add UnityMCP -- \"{uvPath}\" --directory \"{pythonDir}\" run server.py"; - } - } - else + // Resolve claude and uv; then run register command + string claudePath = ExecPath.ResolveClaude(); + if (string.IsNullOrEmpty(claudePath)) { - // Use full path to claude command - command = "/usr/local/bin/claude"; - args = $"mcp add UnityMCP -- uv --directory \"{pythonDir}\" run server.py"; + UnityEngine.Debug.LogError("UnityMCP: Claude CLI not found. Set a path in this window or install the CLI, then try again."); + return; } + string uvPath = ExecPath.ResolveUv() ?? "uv"; - try - { - // Get the Unity project directory (where the Assets folder is) - string unityProjectDir = Application.dataPath; - string projectDir = Path.GetDirectoryName(unityProjectDir); - - var psi = new ProcessStartInfo(); - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // On Windows, run through PowerShell with explicit PATH setting - psi.FileName = "powershell.exe"; - string nodePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "nodejs"); - psi.Arguments = $"-Command \"$env:PATH += ';{nodePath}'; & '{command}' {args}\""; - UnityEngine.Debug.Log($"Executing: powershell.exe {psi.Arguments}"); - } - else - { - psi.FileName = command; - psi.Arguments = args; - UnityEngine.Debug.Log($"Executing: {command} {args}"); - } - - psi.UseShellExecute = false; - psi.RedirectStandardOutput = true; - psi.RedirectStandardError = true; - psi.CreateNoWindow = true; - psi.WorkingDirectory = projectDir; - - // Set PATH to include common binary locations (OS-specific) - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Windows: Add common Node.js and npm locations - string[] windowsPaths = { - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "nodejs"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "nodejs"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "npm"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "npm") - }; - string additionalPaths = string.Join(";", windowsPaths); - psi.EnvironmentVariables["PATH"] = $"{currentPath};{additionalPaths}"; - } - else - { - // macOS/Linux: Add common binary locations - string additionalPaths = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"; - psi.EnvironmentVariables["PATH"] = $"{additionalPaths}:{currentPath}"; - } + // Prefer embedded/dev path when available + string srcDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); + if (string.IsNullOrEmpty(srcDir)) srcDir = pythonDir; - using var process = Process.Start(psi); - string output = process.StandardOutput.ReadToEnd(); - string errors = process.StandardError.ReadToEnd(); - process.WaitForExit(); + string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{srcDir}\" server.py"; - - - // Check for success or already exists - if (output.Contains("Added stdio MCP server") || errors.Contains("already exists")) - { - // Force refresh the configuration status - var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); - if (claudeClient != null) - { - CheckClaudeCodeConfiguration(claudeClient); - } - Repaint(); - UnityEngine.Debug.Log("UnityMCP server successfully registered from Claude Code."); - - - } - else if (!string.IsNullOrEmpty(errors)) - { - if (debugLogsEnabled) - { - UnityEngine.Debug.LogWarning($"Claude MCP errors: {errors}"); - } - } - } - catch (Exception e) + string projectDir = Path.GetDirectoryName(Application.dataPath); + // Ensure PATH includes common Node/npm locations so claude can spawn node internally if needed + string pathPrepend = Application.platform == RuntimePlatform.OSXEditor + ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + : "/usr/local/bin:/usr/bin:/bin"; + if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) { - UnityEngine.Debug.LogError($"Claude CLI registration failed: {e.Message}"); + UnityEngine.Debug.LogError($"UnityMCP: Failed to start Claude CLI.\n{stderr}\n{stdout}"); + return; } + + // Update status + var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (claudeClient != null) CheckClaudeCodeConfiguration(claudeClient); + Repaint(); + UnityEngine.Debug.Log("UNITY-MCP: Registered with Claude Code."); } private void UnregisterWithClaudeCode() { - string command; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + string claudePath = ExecPath.ResolveClaude(); + if (string.IsNullOrEmpty(claudePath)) { - command = FindClaudeCommand(); - - if (string.IsNullOrEmpty(command)) - { - UnityEngine.Debug.LogError("Claude CLI not found. Please ensure Claude Code is installed and accessible."); - return; - } - } - else - { - // Use full path to claude command - command = "/usr/local/bin/claude"; + UnityEngine.Debug.LogError("UnityMCP: Claude CLI not found. Set a path in this window or install the CLI, then try again."); + return; } - try - { - // Get the Unity project directory (where the Assets folder is) - string unityProjectDir = Application.dataPath; - string projectDir = Path.GetDirectoryName(unityProjectDir); + string projectDir = Path.GetDirectoryName(Application.dataPath); + string pathPrepend = Application.platform == RuntimePlatform.OSXEditor + ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + : "/usr/local/bin:/usr/bin:/bin"; - var psi = new ProcessStartInfo(); - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // On Windows, run through PowerShell with explicit PATH setting - psi.FileName = "powershell.exe"; - string nodePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "nodejs"); - psi.Arguments = $"-Command \"$env:PATH += ';{nodePath}'; & '{command}' mcp remove UnityMCP\""; - } - else - { - psi.FileName = command; - psi.Arguments = "mcp remove UnityMCP"; - } - - psi.UseShellExecute = false; - psi.RedirectStandardOutput = true; - psi.RedirectStandardError = true; - psi.CreateNoWindow = true; - psi.WorkingDirectory = projectDir; - - // Set PATH to include common binary locations (OS-specific) - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Windows: Add common Node.js and npm locations - string[] windowsPaths = { - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "nodejs"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "nodejs"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "npm"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "npm") - }; - string additionalPaths = string.Join(";", windowsPaths); - psi.EnvironmentVariables["PATH"] = $"{currentPath};{additionalPaths}"; - } - else - { - // macOS/Linux: Add common binary locations - string additionalPaths = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"; - psi.EnvironmentVariables["PATH"] = $"{additionalPaths}:{currentPath}"; - } - - using var process = Process.Start(psi); - string output = process.StandardOutput.ReadToEnd(); - string errors = process.StandardError.ReadToEnd(); - process.WaitForExit(); - - // Check for success - if (output.Contains("Removed MCP server") || process.ExitCode == 0) - { - // Force refresh the configuration status - var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); - if (claudeClient != null) - { - CheckClaudeCodeConfiguration(claudeClient); - } - Repaint(); - - UnityEngine.Debug.Log("UnityMCP server successfully unregistered from Claude Code."); - } - else if (!string.IsNullOrEmpty(errors)) + if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) + { + var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (claudeClient != null) { - UnityEngine.Debug.LogWarning($"Claude MCP removal errors: {errors}"); + CheckClaudeCodeConfiguration(claudeClient); } + Repaint(); + UnityEngine.Debug.Log("UnityMCP server successfully unregistered from Claude Code."); } - catch (Exception e) + else { - UnityEngine.Debug.LogError($"Claude CLI unregistration failed: {e.Message}"); + UnityEngine.Debug.LogWarning($"Claude MCP removal failed: {stderr}\n{stdout}"); } } From 6b3a20dd788889d7af63c274c76b8d620ed28f9e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 12 Aug 2025 10:34:20 -0700 Subject: [PATCH 42/69] chore(package): add .meta files for new helpers and bump version to 2.0.1 --- UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta | 13 +++++++++++++ .../Editor/Helpers/ServerPathResolver.cs.meta | 13 +++++++++++++ UnityMcpBridge/package.json | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta create mode 100644 UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta diff --git a/UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta b/UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta new file mode 100644 index 00000000..452749ee --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta @@ -0,0 +1,13 @@ +fileFormatVersion: 2 +guid: 8f2b7b3e9c3e4a0f9b2a1d4c7e6f5a12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +fileFormatVersion: 2 +guid: 3f130216be0fd4a57ab7d646a85c6d54 \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta b/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta new file mode 100644 index 00000000..e0a835a2 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta @@ -0,0 +1,13 @@ +fileFormatVersion: 2 +guid: a4d1d7c2b1e94b3f8a7d9c6e5f403a21 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +fileFormatVersion: 2 +guid: 9ac156bc74460420290ab50ed91d3a15 \ No newline at end of file diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index 0e3ccdfc..1091f69f 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "2.0.0", + "version": "2.0.1", "displayName": "Unity MCP Bridge", "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", "unity": "2020.3", From f6f8b243715e3c6c6622d8d8b6ecc03eae378b6c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 12 Aug 2025 10:48:46 -0700 Subject: [PATCH 43/69] chore(uv): prepend ~/.local/bin and common bins to PATH for 'which uv' in GUI env --- UnityMcpBridge/Editor/Helpers/ServerInstaller.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 0c3138bf..1724e0c7 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -328,6 +328,22 @@ internal static string FindUvPath() RedirectStandardError = true, CreateNoWindow = true }; + try + { + // Prepend common user-local and package manager locations so 'which' can see them in Unity's GUI env + string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string prepend = string.Join(":", new[] + { + System.IO.Path.Combine(homeDir, ".local", "bin"), + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin" + }); + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + whichPsi.Environment["PATH"] = string.IsNullOrEmpty(currentPath) ? prepend : (prepend + ":" + currentPath); + } + catch { } using var wp = System.Diagnostics.Process.Start(whichPsi); string output = wp.StandardOutput.ReadToEnd().Trim(); wp.WaitForExit(3000); From efd146ab530f3e75fd710ef00271e997394339f5 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 12 Aug 2025 11:56:46 -0700 Subject: [PATCH 44/69] fix: Windows define UNITY_EDITOR_WIN; async stdout/stderr in TryRun and RepairPythonEnvironment; use EnvironmentVariables for PATH; prepend Unix PATH only on macOS/Linux; fix duplicate .meta GUIDs --- UnityMcpBridge/Editor/Helpers/ExecPath.cs | 40 ++++++++++++++----- .../Editor/Helpers/ExecPath.cs.meta | 4 +- .../Editor/Helpers/ServerInstaller.cs | 36 ++++++++++++++--- .../Editor/Helpers/ServerPathResolver.cs.meta | 4 +- .../Editor/Windows/UnityMcpEditorWindow.cs | 12 ++++-- 5 files changed, 70 insertions(+), 26 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ExecPath.cs b/UnityMcpBridge/Editor/Helpers/ExecPath.cs index 1848dcab..537962e6 100644 --- a/UnityMcpBridge/Editor/Helpers/ExecPath.cs +++ b/UnityMcpBridge/Editor/Helpers/ExecPath.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Text; using System.Runtime.InteropServices; using UnityEditor; @@ -43,7 +44,7 @@ internal static string ResolveClaude() if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { -#if UNITY_EDITOR_WINDOWS +#if UNITY_EDITOR_WIN // Common npm global locations string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; @@ -109,16 +110,35 @@ internal static bool TryRun( if (!string.IsNullOrEmpty(extraPathPrepend)) { string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - psi.Environment["PATH"] = string.IsNullOrEmpty(currentPath) + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) ? extraPathPrepend : (extraPathPrepend + System.IO.Path.PathSeparator + currentPath); } - using var p = Process.Start(psi); - if (p == null) return false; - stdout = p.StandardOutput.ReadToEnd(); - stderr = p.StandardError.ReadToEnd(); - if (!p.WaitForExit(timeoutMs)) { try { p.Kill(); } catch { } return false; } - return p.ExitCode == 0; + + using var process = new Process { StartInfo = psi, EnableRaisingEvents = false }; + + var so = new StringBuilder(); + var se = new StringBuilder(); + process.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; + + if (!process.Start()) return false; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(timeoutMs)) + { + try { process.Kill(); } catch { } + return false; + } + + // Ensure async buffers are flushed + process.WaitForExit(); + + stdout = so.ToString(); + stderr = se.ToString(); + return process.ExitCode == 0; } catch { @@ -138,7 +158,7 @@ private static string Which(string exe, string prependPath) CreateNoWindow = true, }; string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - psi.Environment["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); using var p = Process.Start(psi); string output = p?.StandardOutput.ReadToEnd().Trim(); p?.WaitForExit(1500); @@ -148,7 +168,7 @@ private static string Which(string exe, string prependPath) } #endif -#if UNITY_EDITOR_WINDOWS +#if UNITY_EDITOR_WIN private static string Where(string exe) { try diff --git a/UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta b/UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta index 452749ee..aba921ed 100644 --- a/UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta +++ b/UnityMcpBridge/Editor/Helpers/ExecPath.cs.meta @@ -8,6 +8,4 @@ MonoImporter: icon: {instanceID: 0} userData: assetBundleName: - assetBundleVariant: -fileFormatVersion: 2 -guid: 3f130216be0fd4a57ab7d646a85c6d54 \ No newline at end of file + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 1724e0c7..dbdfb743 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Runtime.InteropServices; +using System.Text; using System.Reflection; using UnityEditor; using UnityEngine; @@ -206,12 +207,35 @@ public static bool RepairPythonEnvironment() CreateNoWindow = true }; - using var p = System.Diagnostics.Process.Start(psi); - string stdout = p.StandardOutput.ReadToEnd(); - string stderr = p.StandardError.ReadToEnd(); - p.WaitForExit(60000); + using var proc = new System.Diagnostics.Process { StartInfo = psi }; + var sbOut = new StringBuilder(); + var sbErr = new StringBuilder(); + proc.OutputDataReceived += (_, e) => { if (e.Data != null) sbOut.AppendLine(e.Data); }; + proc.ErrorDataReceived += (_, e) => { if (e.Data != null) sbErr.AppendLine(e.Data); }; + + if (!proc.Start()) + { + Debug.LogError("Failed to start uv process."); + return false; + } + + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + if (!proc.WaitForExit(60000)) + { + try { proc.Kill(); } catch { } + Debug.LogError("uv sync timed out."); + return false; + } + + // Ensure async buffers flushed + proc.WaitForExit(); + + string stdout = sbOut.ToString(); + string stderr = sbErr.ToString(); - if (p.ExitCode != 0) + if (proc.ExitCode != 0) { Debug.LogError($"uv sync failed: {stderr}\n{stdout}"); return false; @@ -341,7 +365,7 @@ internal static string FindUvPath() "/bin" }); string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - whichPsi.Environment["PATH"] = string.IsNullOrEmpty(currentPath) ? prepend : (prepend + ":" + currentPath); + whichPsi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) ? prepend : (prepend + ":" + currentPath); } catch { } using var wp = System.Diagnostics.Process.Start(whichPsi); diff --git a/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta b/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta index e0a835a2..d02df608 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta +++ b/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs.meta @@ -8,6 +8,4 @@ MonoImporter: icon: {instanceID: 0} userData: assetBundleName: - assetBundleVariant: -fileFormatVersion: 2 -guid: 9ac156bc74460420290ab50ed91d3a15 \ No newline at end of file + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index f86acd1c..17af9785 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1320,10 +1320,14 @@ private void RegisterWithClaudeCode(string pythonDir) string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{srcDir}\" server.py"; string projectDir = Path.GetDirectoryName(Application.dataPath); - // Ensure PATH includes common Node/npm locations so claude can spawn node internally if needed - string pathPrepend = Application.platform == RuntimePlatform.OSXEditor - ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" - : "/usr/local/bin:/usr/bin:/bin"; + // Ensure PATH includes common locations on Unix; on Windows leave PATH as-is + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor || Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = Application.platform == RuntimePlatform.OSXEditor + ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + : "/usr/local/bin:/usr/bin:/bin"; + } if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) { UnityEngine.Debug.LogError($"UnityMCP: Failed to start Claude CLI.\n{stderr}\n{stdout}"); From bd6114b4364a84e59a199bcb5a3b9a8fc9bae106 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 12 Aug 2025 12:14:48 -0700 Subject: [PATCH 45/69] fix(claude): treat 'already exists' as success; improve IsClaudeConfigured using ExecPath on all OSes --- .../Editor/Windows/UnityMcpEditorWindow.cs | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 17af9785..b212719d 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -654,13 +654,23 @@ private static bool IsClaudeConfigured() { try { - string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "claude" : "/usr/local/bin/claude"; - var psi = new ProcessStartInfo { FileName = command, Arguments = "mcp list", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; - using var p = Process.Start(psi); - string output = p.StandardOutput.ReadToEnd(); - p.WaitForExit(3000); - if (p.ExitCode != 0) return false; - return output.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0; + string claudePath = ExecPath.ResolveClaude(); + if (string.IsNullOrEmpty(claudePath)) return false; + + // Only prepend PATH on Unix + string pathPrepend = null; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + pathPrepend = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + : "/usr/local/bin:/usr/bin:/bin"; + } + + if (!ExecPath.TryRun(claudePath, "mcp list", workingDir: null, out var stdout, out var stderr, 5000, pathPrepend)) + { + return false; + } + return (stdout ?? string.Empty).IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0; } catch { return false; } } @@ -1330,7 +1340,19 @@ private void RegisterWithClaudeCode(string pythonDir) } if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) { - UnityEngine.Debug.LogError($"UnityMCP: Failed to start Claude CLI.\n{stderr}\n{stdout}"); + string combined = ($"{stdout}\n{stderr}") ?? string.Empty; + if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) + { + // Treat as success if Claude reports existing registration + var existingClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (existingClient != null) CheckClaudeCodeConfiguration(existingClient); + Repaint(); + UnityEngine.Debug.Log("UNITY-MCP: UnityMCP already registered with Claude Code."); + } + else + { + UnityEngine.Debug.LogError($"UnityMCP: Failed to start Claude CLI.\n{stderr}\n{stdout}"); + } return; } From 5965158533451f9f3dc436a14920c8a7629103fc Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 12 Aug 2025 19:04:47 -0700 Subject: [PATCH 46/69] Unity MCP: Claude Code UX improvements: dynamic not-found state with inline help link; NVM auto-detection; path picker override; hide picker after detection; remove auto-connect toggle. --- UnityMcpBridge/Editor/Helpers/ExecPath.cs | 68 ++++++++++ .../Editor/Windows/UnityMcpEditorWindow.cs | 125 +++++++++++++----- 2 files changed, 160 insertions(+), 33 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ExecPath.cs b/UnityMcpBridge/Editor/Helpers/ExecPath.cs index 537962e6..4403b693 100644 --- a/UnityMcpBridge/Editor/Helpers/ExecPath.cs +++ b/UnityMcpBridge/Editor/Helpers/ExecPath.cs @@ -35,6 +35,9 @@ internal static string ResolveClaude() Path.Combine(home, ".local", "bin", "claude"), }; foreach (string c in candidates) { if (File.Exists(c)) return c; } + // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude + string nvmClaude = ResolveClaudeFromNvm(home); + if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); #else @@ -70,6 +73,9 @@ internal static string ResolveClaude() Path.Combine(home, ".local", "bin", "claude"), }; foreach (string c in candidates) { if (File.Exists(c)) return c; } + // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude + string nvmClaude = ResolveClaudeFromNvm(home); + if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX return Which("claude", "/usr/local/bin:/usr/bin:/bin"); #else @@ -78,6 +84,68 @@ internal static string ResolveClaude() } } + // Attempt to resolve claude from NVM-managed Node installations, choosing the newest version + private static string ResolveClaudeFromNvm(string home) + { + try + { + if (string.IsNullOrEmpty(home)) return null; + string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node"); + if (!Directory.Exists(nvmNodeDir)) return null; + + string bestPath = null; + Version bestVersion = null; + foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir)) + { + string name = Path.GetFileName(versionDir); + if (string.IsNullOrEmpty(name)) continue; + if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + if (Version.TryParse(name.Substring(1), out Version parsed)) + { + string candidate = Path.Combine(versionDir, "bin", "claude"); + if (File.Exists(candidate)) + { + if (bestVersion == null || parsed > bestVersion) + { + bestVersion = parsed; + bestPath = candidate; + } + } + } + } + } + return bestPath; + } + catch { return null; } + } + + // Explicitly set the Claude CLI absolute path override in EditorPrefs + internal static void SetClaudeCliPath(string absolutePath) + { + try + { + if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath)) + { + EditorPrefs.SetString(PrefClaude, absolutePath); + } + } + catch { } + } + + // Clear any previously set Claude CLI override path + internal static void ClearClaudeCliPath() + { + try + { + if (EditorPrefs.HasKey(PrefClaude)) + { + EditorPrefs.DeleteKey(PrefClaude); + } + } + catch { } + } + // Use existing UV resolver; returns absolute path or null. internal static string ResolveUv() { diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index b212719d..47b983e2 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -438,14 +438,7 @@ private void DrawUnifiedClientConfiguration() EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle); EditorGUILayout.Space(10); - // Auto-connect toggle (moved from Server Status) - bool newAuto = EditorGUILayout.ToggleLeft("Auto-connect to MCP Clients", autoRegisterEnabled); - if (newAuto != autoRegisterEnabled) - { - autoRegisterEnabled = newAuto; - EditorPrefs.SetBool("UnityMCP.AutoRegisterEnabled", autoRegisterEnabled); - } - EditorGUILayout.Space(6); + // (Auto-connect toggle removed per design) // Client selector string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray(); @@ -697,6 +690,17 @@ private static bool VerifyBridgePing(int port) private void DrawClientConfigurationCompact(McpClient mcpClient) { + // Special pre-check for Claude Code: if CLI missing, reflect in status UI + if (mcpClient.mcpType == McpTypes.ClaudeCode) + { + string claudeCheck = ExecPath.ResolveClaude(); + if (string.IsNullOrEmpty(claudeCheck)) + { + mcpClient.configStatus = "Claude Not Found"; + mcpClient.status = McpStatus.NotConfigured; + } + } + // Status display EditorGUILayout.BeginHorizontal(); Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); @@ -710,7 +714,24 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) }; EditorGUILayout.LabelField(mcpClient.configStatus, clientStatusStyle, GUILayout.Height(28)); EditorGUILayout.EndHorizontal(); - + // When Claude CLI is missing, show a clear install hint directly below status + if (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) + { + GUIStyle installHintStyle = new GUIStyle(clientStatusStyle); + installHintStyle.normal.textColor = new Color(1f, 0.5f, 0f); // orange + EditorGUILayout.BeginHorizontal(); + GUIContent installText = new GUIContent("Make sure Claude Code is installed!"); + Vector2 textSize = installHintStyle.CalcSize(installText); + EditorGUILayout.LabelField(installText, installHintStyle, GUILayout.Height(22), GUILayout.Width(textSize.x + 2), GUILayout.ExpandWidth(false)); + GUIStyle helpLinkStyle = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; + GUILayout.Space(6); + if (GUILayout.Button("[CLICK]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false))) + { + Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Claude-Code"); + } + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.Space(10); // Action buttons in horizontal layout @@ -723,23 +744,57 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) ConfigureMcpClient(mcpClient); } } - else if (mcpClient.mcpType == McpTypes.ClaudeCode) - { - bool isConfigured = mcpClient.status == McpStatus.Configured; - string buttonText = isConfigured ? "Unregister UnityMCP with Claude Code" : "Register with Claude Code"; - if (GUILayout.Button(buttonText, GUILayout.Height(32))) - { - if (isConfigured) - { - UnregisterWithClaudeCode(); - } - else - { - string pythonDir = FindPackagePythonDirectory(); - RegisterWithClaudeCode(pythonDir); - } - } - } + else if (mcpClient.mcpType == McpTypes.ClaudeCode) + { + bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude()); + if (claudeAvailable) + { + bool isConfigured = mcpClient.status == McpStatus.Configured; + string buttonText = isConfigured ? "Unregister UnityMCP with Claude Code" : "Register with Claude Code"; + if (GUILayout.Button(buttonText, GUILayout.Height(32))) + { + if (isConfigured) + { + UnregisterWithClaudeCode(); + } + else + { + string pythonDir = FindPackagePythonDirectory(); + RegisterWithClaudeCode(pythonDir); + } + } + // Hide the picker once a valid binary is available + EditorGUILayout.EndHorizontal(); + EditorGUILayout.BeginHorizontal(); + GUIStyle pathLabelStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = true }; + string resolvedClaude = ExecPath.ResolveClaude(); + EditorGUILayout.LabelField($"Claude CLI: {resolvedClaude}", pathLabelStyle); + EditorGUILayout.EndHorizontal(); + EditorGUILayout.BeginHorizontal(); + } + // CLI picker row (only when not found) + EditorGUILayout.EndHorizontal(); + EditorGUILayout.BeginHorizontal(); + if (!claudeAvailable) + { + // Only show the picker button in not-found state (no redundant "not found" label) + if (GUILayout.Button("Choose Claude Install Location", GUILayout.Width(260), GUILayout.Height(22))) + { + string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string picked = EditorUtility.OpenFilePanel("Select 'claude' CLI", suggested, ""); + if (!string.IsNullOrEmpty(picked)) + { + ExecPath.SetClaudeCliPath(picked); + // Auto-register after setting a valid path + string pythonDir = FindPackagePythonDirectory(); + RegisterWithClaudeCode(pythonDir); + Repaint(); + } + } + } + EditorGUILayout.EndHorizontal(); + EditorGUILayout.BeginHorizontal(); + } else { if (GUILayout.Button($"Auto Configure", GUILayout.Height(32))) @@ -793,13 +848,17 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) EditorGUILayout.EndHorizontal(); - EditorGUILayout.Space(8); - // Quick info - GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel) - { - fontSize = 10 - }; - EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle); + EditorGUILayout.Space(8); + // Quick info (hide when Claude is not found to avoid confusion) + bool hideConfigInfo = (mcpClient.mcpType == McpTypes.ClaudeCode) && string.IsNullOrEmpty(ExecPath.ResolveClaude()); + if (!hideConfigInfo) + { + GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel) + { + fontSize = 10 + }; + EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle); + } } private void ToggleUnityBridge() From 4f9017d6764dcfa5f67c57e986501c6170ee69b4 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 12 Aug 2025 20:56:22 -0700 Subject: [PATCH 47/69] VSCode MCP: switch to mcp.json top-level servers schema; add type=stdio; robust parse/merge; Cursor/Windsurf UV gating UI; Claude Code UX polish and NVM detection --- UnityMcpBridge/Editor/Data/McpClients.cs | 4 +- .../Editor/Models/MCPConfigServer.cs | 4 + .../Editor/Windows/UnityMcpEditorWindow.cs | 122 ++++++++++++++---- 3 files changed, 103 insertions(+), 27 deletions(-) diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index 362ecdcc..9d9cbae5 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -87,7 +87,7 @@ public class McpClients Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", - "settings.json" + "mcp.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), @@ -95,7 +95,7 @@ public class McpClients "Application Support", "Code", "User", - "settings.json" + "mcp.json" ), mcpType = McpTypes.VSCode, configStatus = "Not Configured", diff --git a/UnityMcpBridge/Editor/Models/MCPConfigServer.cs b/UnityMcpBridge/Editor/Models/MCPConfigServer.cs index 87d953d8..edc0de20 100644 --- a/UnityMcpBridge/Editor/Models/MCPConfigServer.cs +++ b/UnityMcpBridge/Editor/Models/MCPConfigServer.cs @@ -11,5 +11,9 @@ public class McpConfigServer [JsonProperty("args")] public string[] args; + + // VSCode expects a transport type; default to stdio for compatibility + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string type = "stdio"; } } diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 47b983e2..5b16e254 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -701,6 +701,20 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) } } + // Pre-check for clients that require uv (all except Claude Code) + bool uvRequired = mcpClient.mcpType != McpTypes.ClaudeCode; + bool uvMissingEarly = false; + if (uvRequired) + { + string uvPathEarly = FindUvPath(); + if (string.IsNullOrEmpty(uvPathEarly)) + { + uvMissingEarly = true; + mcpClient.configStatus = "uv Not Found"; + mcpClient.status = McpStatus.NotConfigured; + } + } + // Status display EditorGUILayout.BeginHorizontal(); Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); @@ -732,7 +746,46 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) EditorGUILayout.EndHorizontal(); } - EditorGUILayout.Space(10); + EditorGUILayout.Space(10); + + // If uv is missing for required clients, show hint and picker then exit early to avoid showing other controls + if (uvRequired && uvMissingEarly) + { + GUIStyle installHintStyle2 = new GUIStyle(EditorStyles.label) + { + fontSize = 12, + fontStyle = FontStyle.Bold, + wordWrap = false + }; + installHintStyle2.normal.textColor = new Color(1f, 0.5f, 0f); + EditorGUILayout.BeginHorizontal(); + GUIContent installText2 = new GUIContent("Make sure uv is installed!"); + Vector2 sz = installHintStyle2.CalcSize(installText2); + EditorGUILayout.LabelField(installText2, installHintStyle2, GUILayout.Height(22), GUILayout.Width(sz.x + 2), GUILayout.ExpandWidth(false)); + GUIStyle helpLinkStyle2 = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; + GUILayout.Space(6); + if (GUILayout.Button("[CLICK]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false))) + { + Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Cursor,-VSCode-&-Windsurf"); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(8); + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Choose UV Install Location", GUILayout.Width(260), GUILayout.Height(22))) + { + string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string picked = EditorUtility.OpenFilePanel("Select 'uv' binary", suggested, ""); + if (!string.IsNullOrEmpty(picked)) + { + EditorPrefs.SetString("UnityMCP.UvPath", picked); + ConfigureMcpClient(mcpClient); + Repaint(); + } + } + EditorGUILayout.EndHorizontal(); + return; + } // Action buttons in horizontal layout EditorGUILayout.BeginHorizontal(); @@ -850,7 +903,9 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) EditorGUILayout.Space(8); // Quick info (hide when Claude is not found to avoid confusion) - bool hideConfigInfo = (mcpClient.mcpType == McpTypes.ClaudeCode) && string.IsNullOrEmpty(ExecPath.ResolveClaude()); + bool hideConfigInfo = + (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) + || ((mcpClient.mcpType != McpTypes.ClaudeCode) && string.IsNullOrEmpty(FindUvPath())); if (!hideConfigInfo) { GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel) @@ -889,6 +944,7 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC { command = uvPath, args = new[] { "--directory", pythonDir, "run", "server.py" }, + type = "stdio", }; JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; @@ -908,29 +964,41 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC } // Parse the existing JSON while preserving all properties - dynamic existingConfig = JsonConvert.DeserializeObject(existingJson); - existingConfig ??= new Newtonsoft.Json.Linq.JObject(); + dynamic existingConfig; + try + { + if (string.IsNullOrWhiteSpace(existingJson)) + { + existingConfig = new Newtonsoft.Json.Linq.JObject(); + } + else + { + existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new Newtonsoft.Json.Linq.JObject(); + } + } + catch + { + // If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object + if (!string.IsNullOrWhiteSpace(existingJson)) + { + UnityEngine.Debug.LogWarning("UnityMCP: VSCode mcp.json could not be parsed; rewriting servers block."); + } + existingConfig = new Newtonsoft.Json.Linq.JObject(); + } // Handle different client types with a switch statement //Comments: Interestingly, VSCode has mcp.servers.unityMCP while others have mcpServers.unityMCP, which is why we need to prevent this switch (mcpClient?.mcpType) { case McpTypes.VSCode: - // VSCode specific configuration - // Ensure mcp object exists - if (existingConfig.mcp == null) - { - existingConfig.mcp = new Newtonsoft.Json.Linq.JObject(); - } - - // Ensure mcp.servers object exists - if (existingConfig.mcp.servers == null) + // VSCode-specific configuration (top-level "servers") + if (existingConfig.servers == null) { - existingConfig.mcp.servers = new Newtonsoft.Json.Linq.JObject(); + existingConfig.servers = new Newtonsoft.Json.Linq.JObject(); } - // Add/update UnityMCP server in VSCode settings - existingConfig.mcp.servers.unityMCP = + // Add/update UnityMCP server in VSCode mcp.json + existingConfig.servers.unityMCP = JsonConvert.DeserializeObject( JsonConvert.SerializeObject(unityMCPConfig) ); @@ -986,15 +1054,13 @@ private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient // Create VSCode-specific configuration with proper format var vscodeConfig = new { - mcp = new + servers = new { - servers = new + unityMCP = new { - unityMCP = new - { - command = "uv", - args = new[] { "--directory", pythonDir, "run", "server.py" } - } + command = "uv", + args = new[] { "--directory", pythonDir, "run", "server.py" }, + type = "stdio" } } }; @@ -1303,9 +1369,15 @@ private void CheckMcpConfiguration(McpClient mcpClient) case McpTypes.VSCode: dynamic config = JsonConvert.DeserializeObject(configJson); - if (config?.mcp?.servers?.unityMCP != null) + // New schema: top-level servers + if (config?.servers?.unityMCP != null) + { + args = config.servers.unityMCP.args.ToObject(); + configExists = true; + } + // Back-compat: legacy mcp.servers + else if (config?.mcp?.servers?.unityMCP != null) { - // Extract args from VSCode config format args = config.mcp.servers.unityMCP.args.ToObject(); configExists = true; } From eb7b2e952ebeaa49635ac8a76af101472134150d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 12 Aug 2025 21:09:02 -0700 Subject: [PATCH 48/69] chore: bump Unity MCP Bridge package version --- UnityMcpBridge/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index 1091f69f..ba4add49 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "2.0.1", + "version": "2.0.2", "displayName": "Unity MCP Bridge", "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", "unity": "2020.3", From a52ce7a2190f74471e6e075c54290bcf46a82dd0 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 12 Aug 2025 21:33:43 -0700 Subject: [PATCH 49/69] VSCode manual config: use resolved uv path; VSCode parse init guards; NVM version parse robustness; help labels [HELP] --- UnityMcpBridge/Editor/Helpers/ExecPath.cs | 9 +++- .../Editor/Models/MCPConfigServer.cs | 4 +- .../Editor/Windows/UnityMcpEditorWindow.cs | 43 +++++++++++-------- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ExecPath.cs b/UnityMcpBridge/Editor/Helpers/ExecPath.cs index 4403b693..ab55fd6a 100644 --- a/UnityMcpBridge/Editor/Helpers/ExecPath.cs +++ b/UnityMcpBridge/Editor/Helpers/ExecPath.cs @@ -101,7 +101,14 @@ private static string ResolveClaudeFromNvm(string home) if (string.IsNullOrEmpty(name)) continue; if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase)) { - if (Version.TryParse(name.Substring(1), out Version parsed)) + // Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0 + string versionStr = name.Substring(1); + int dashIndex = versionStr.IndexOf('-'); + if (dashIndex > 0) + { + versionStr = versionStr.Substring(0, dashIndex); + } + if (Version.TryParse(versionStr, out Version parsed)) { string candidate = Path.Combine(versionDir, "bin", "claude"); if (File.Exists(candidate)) diff --git a/UnityMcpBridge/Editor/Models/MCPConfigServer.cs b/UnityMcpBridge/Editor/Models/MCPConfigServer.cs index edc0de20..2c2596ff 100644 --- a/UnityMcpBridge/Editor/Models/MCPConfigServer.cs +++ b/UnityMcpBridge/Editor/Models/MCPConfigServer.cs @@ -12,8 +12,8 @@ public class McpConfigServer [JsonProperty("args")] public string[] args; - // VSCode expects a transport type; default to stdio for compatibility + // VSCode expects a transport type; include only when explicitly set [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] - public string type = "stdio"; + public string type; } } diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 5b16e254..587eb541 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -739,7 +739,7 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) EditorGUILayout.LabelField(installText, installHintStyle, GUILayout.Height(22), GUILayout.Width(textSize.x + 2), GUILayout.ExpandWidth(false)); GUIStyle helpLinkStyle = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; GUILayout.Space(6); - if (GUILayout.Button("[CLICK]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false))) + if (GUILayout.Button("[HELP]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false))) { Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Claude-Code"); } @@ -764,7 +764,7 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) EditorGUILayout.LabelField(installText2, installHintStyle2, GUILayout.Height(22), GUILayout.Width(sz.x + 2), GUILayout.ExpandWidth(false)); GUIStyle helpLinkStyle2 = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; GUILayout.Space(6); - if (GUILayout.Button("[CLICK]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false))) + if (GUILayout.Button("[HELP]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false))) { Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Cursor,-VSCode-&-Windsurf"); } @@ -1050,22 +1050,29 @@ private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient // Use switch statement to handle different client types switch (mcpClient.mcpType) { - case McpTypes.VSCode: - // Create VSCode-specific configuration with proper format - var vscodeConfig = new - { - servers = new - { - unityMCP = new - { - command = "uv", - args = new[] { "--directory", pythonDir, "run", "server.py" }, - type = "stdio" - } - } - }; - manualConfigJson = JsonConvert.SerializeObject(vscodeConfig, jsonSettings); - break; + case McpTypes.VSCode: + // Resolve uv so VSCode launches the correct executable even if not on PATH + string uvPathManual = FindUvPath(); + if (uvPathManual == null) + { + UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration."); + return; + } + // Create VSCode-specific configuration with proper format + var vscodeConfig = new + { + servers = new + { + unityMCP = new + { + command = uvPathManual, + args = new[] { "--directory", pythonDir, "run", "server.py" }, + type = "stdio" + } + } + }; + manualConfigJson = JsonConvert.SerializeObject(vscodeConfig, jsonSettings); + break; default: // Create standard MCP configuration for other clients From b09a86f5fb91fb58632341567df5401e80dd3a46 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 12 Aug 2025 21:47:11 -0700 Subject: [PATCH 50/69] WriteToConfig: only include type="stdio" for VSCode; omit for other clients --- UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 587eb541..e5354bad 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -944,8 +944,11 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC { command = uvPath, args = new[] { "--directory", pythonDir, "run", "server.py" }, - type = "stdio", }; + if (mcpClient?.mcpType == McpTypes.VSCode) + { + unityMCPConfig.type = "stdio"; + } JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; From 9a9267c1280c70dda576ac41eb6702a982e66860 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 13 Aug 2025 11:35:31 -0700 Subject: [PATCH 51/69] Windows: prefer WinGet Links uv.exe and preserve existing absolute uv command during config writes --- .../Editor/Helpers/ServerInstaller.cs | 28 ++++++--- .../Editor/Windows/UnityMcpEditorWindow.cs | 62 +++++++++++++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index dbdfb743..aa845898 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -270,19 +270,29 @@ internal static string FindUvPath() string[] candidates; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty; + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + candidates = new[] { + // Preferred: WinGet Links shims (stable entrypoints) + Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"), + Path.Combine(programFiles, "WinGet", "Links", "uv.exe"), + // Common per-user installs - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python313\Scripts\uv.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python312\Scripts\uv.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python311\Scripts\uv.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python310\Scripts\uv.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python313\Scripts\uv.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python312\Scripts\uv.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python311\Scripts\uv.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python310\Scripts\uv.exe"), + Path.Combine(localAppData, @"Programs\Python\Python313\Scripts\uv.exe"), + Path.Combine(localAppData, @"Programs\Python\Python312\Scripts\uv.exe"), + Path.Combine(localAppData, @"Programs\Python\Python311\Scripts\uv.exe"), + Path.Combine(localAppData, @"Programs\Python\Python310\Scripts\uv.exe"), + Path.Combine(appData, @"Python\Python313\Scripts\uv.exe"), + Path.Combine(appData, @"Python\Python312\Scripts\uv.exe"), + Path.Combine(appData, @"Python\Python311\Scripts\uv.exe"), + Path.Combine(appData, @"Python\Python310\Scripts\uv.exe"), + // Program Files style installs (if a native installer was used) - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty, @"uv\uv.exe"), + Path.Combine(programFiles, @"uv\uv.exe"), + // Try simple name resolution later via PATH "uv.exe", "uv" diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index e5354bad..821e03c3 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1023,6 +1023,68 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC break; } + // If config already has a working absolute uv path, avoid rewriting it on refresh + try + { + if (mcpClient?.mcpType != McpTypes.ClaudeCode) + { + // Inspect existing command for stability (Windows absolute path that exists) + string existingCommand = null; + if (mcpClient?.mcpType == McpTypes.VSCode) + { + existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString(); + } + else + { + existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString(); + } + + if (!string.IsNullOrEmpty(existingCommand)) + { + bool keep = false; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Consider absolute, existing paths as stable; prefer WinGet Links + if (Path.IsPathRooted(existingCommand) && File.Exists(existingCommand)) + { + keep = true; + } + } + else + { + // On Unix, keep absolute existing path as well + if (Path.IsPathRooted(existingCommand) && File.Exists(existingCommand)) + { + keep = true; + } + } + + if (keep) + { + // Merge without replacing the existing command + if (mcpClient?.mcpType == McpTypes.VSCode) + { + existingConfig.servers.unityMCP.args = + JsonConvert.DeserializeObject( + JsonConvert.SerializeObject(unityMCPConfig.args) + ); + } + else + { + existingConfig.mcpServers.unityMCP.args = + JsonConvert.DeserializeObject( + JsonConvert.SerializeObject(unityMCPConfig.args) + ); + } + string mergedKeep = JsonConvert.SerializeObject(existingConfig, jsonSettings); + File.WriteAllText(configPath, mergedKeep); + return "Configured successfully"; + } + } + } + } + catch { /* fall back to normal write */ } + // Write the merged configuration back to file string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); File.WriteAllText(configPath, mergedJson); From a2a14c179cc874d798bc5b392b0e95fd9272d8a7 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 13 Aug 2025 11:54:07 -0700 Subject: [PATCH 52/69] Claude Code: after unregister, set NotConfigured, re-check, and repaint so button toggles and status updates --- UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 821e03c3..079e3e9a 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1585,6 +1585,8 @@ private void UnregisterWithClaudeCode() var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) { + // Optimistically flip to NotConfigured; then verify + claudeClient.SetStatus(McpStatus.NotConfigured); CheckClaudeCodeConfiguration(claudeClient); } Repaint(); @@ -1593,6 +1595,12 @@ private void UnregisterWithClaudeCode() else { UnityEngine.Debug.LogWarning($"Claude MCP removal failed: {stderr}\n{stdout}"); + var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (claudeClient != null) + { + CheckClaudeCodeConfiguration(claudeClient); + } + Repaint(); } } From b6b8d47dfed6a2131e164fe4a77d55a0f8926e04 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 13 Aug 2025 12:36:24 -0700 Subject: [PATCH 53/69] Windows: robust Claude CLI resolution (prefer .cmd, fallback .ps1, where.exe); Unregister UX: use 'claude mcp get' exit codes; stop PATH prepend on Windows; safer detection when unregistered --- UnityMcpBridge/Editor/Helpers/ExecPath.cs | 6 +- .../Editor/Windows/UnityMcpEditorWindow.cs | 80 ++++++++++++++++++- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ExecPath.cs b/UnityMcpBridge/Editor/Helpers/ExecPath.cs index ab55fd6a..99dcf5a7 100644 --- a/UnityMcpBridge/Editor/Helpers/ExecPath.cs +++ b/UnityMcpBridge/Editor/Helpers/ExecPath.cs @@ -53,11 +53,15 @@ internal static string ResolveClaude() string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; string[] candidates = { + // Prefer .cmd (most reliable from non-interactive processes) Path.Combine(appData, "npm", "claude.cmd"), Path.Combine(localAppData, "npm", "claude.cmd"), + // Fall back to PowerShell shim if only .ps1 is present + Path.Combine(appData, "npm", "claude.ps1"), + Path.Combine(localAppData, "npm", "claude.ps1"), }; foreach (string c in candidates) { if (File.Exists(c)) return c; } - string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude"); + string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude.ps1") ?? Where("claude"); if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; #endif return null; diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 079e3e9a..ab8ab32c 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1578,9 +1578,54 @@ private void UnregisterWithClaudeCode() string projectDir = Path.GetDirectoryName(Application.dataPath); string pathPrepend = Application.platform == RuntimePlatform.OSXEditor ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" - : "/usr/local/bin:/usr/bin:/bin"; + : null; // On Windows, don't modify PATH - use system PATH as-is - if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) + // Determine if Claude has a UnityMCP server registered by using exit codes from `claude mcp get ` + string[] candidateNamesForGet = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; + List existingNames = new List(); + foreach (var candidate in candidateNamesForGet) + { + if (ExecPath.TryRun(claudePath, $"mcp get {candidate}", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend)) + { + // Success exit code indicates the server exists + existingNames.Add(candidate); + } + } + + if (existingNames.Count == 0) + { + // Nothing to unregister – set status and bail early + var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (claudeClient != null) + { + claudeClient.SetStatus(McpStatus.NotConfigured); + UnityEngine.Debug.Log("Claude CLI reports no UnityMCP server via 'mcp get' – setting status to NotConfigured and aborting unregister."); + Repaint(); + } + return; + } + + // Try different possible server names + string[] possibleNames = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; + bool success = false; + + foreach (string serverName in possibleNames) + { + if (ExecPath.TryRun(claudePath, $"mcp remove {serverName}", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) + { + success = true; + UnityEngine.Debug.Log($"Successfully removed MCP server: {serverName}"); + break; + } + else if (!stderr.Contains("No MCP server found")) + { + // If it's not a "not found" error, log it and stop trying + UnityEngine.Debug.LogWarning($"Error removing {serverName}: {stderr}"); + break; + } + } + + if (success) { var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) @@ -1594,16 +1639,45 @@ private void UnregisterWithClaudeCode() } else { - UnityEngine.Debug.LogWarning($"Claude MCP removal failed: {stderr}\n{stdout}"); + // If no servers were found to remove, they're already unregistered + // Force status to NotConfigured and update the UI + UnityEngine.Debug.Log("No MCP servers found to unregister - already unregistered."); var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) { + claudeClient.SetStatus(McpStatus.NotConfigured); CheckClaudeCodeConfiguration(claudeClient); } Repaint(); } } + private bool ParseTextOutput(string claudePath, string projectDir, string pathPrepend) + { + if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend)) + { + UnityEngine.Debug.Log($"Claude MCP servers (text): {listStdout}"); + + // Check if output indicates no servers or contains UnityMCP variants + if (listStdout.Contains("No MCP servers configured") || + listStdout.Contains("no servers") || + listStdout.Contains("No servers") || + string.IsNullOrWhiteSpace(listStdout) || + listStdout.Trim().Length == 0) + { + return false; + } + + // Look for UnityMCP variants in the output + return listStdout.Contains("UnityMCP") || + listStdout.Contains("unityMCP") || + listStdout.Contains("unity-mcp"); + } + + // If command failed, assume no servers + return false; + } + private string FindUvPath() { string uvPath = null; From cd707284d7f395291739fcc53d5825a66c6ed104 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 13 Aug 2025 12:42:07 -0700 Subject: [PATCH 54/69] dev: add generic mcp_source.py helper to switch MCP package source (upstream/remote/local) --- mcp_source.py | 241 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100755 mcp_source.py diff --git a/mcp_source.py b/mcp_source.py new file mode 100755 index 00000000..548b2a96 --- /dev/null +++ b/mcp_source.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Generic helper to switch the Unity MCP package source in a Unity project's +Packages/manifest.json without embedding any personal paths. + +Usage: + python mcp_source.py [--manifest /abs/path/to/manifest.json] [--repo /abs/path/to/unity-mcp] [--choice 1|2|3] + +Choices: + 1) Upstream main (CoplayDev/unity-mcp) + 2) Your remote current branch (derived from `origin` and current branch) + 3) Local repo workspace (file: URL to UnityMcpBridge in your checkout) +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import subprocess +import sys +from typing import Optional + +PKG_NAME = "com.coplaydev.unity-mcp" +BRIDGE_SUBPATH = "UnityMcpBridge" + + +def run_git(repo: pathlib.Path, *args: str) -> str: + result = subprocess.run([ + "git", "-C", str(repo), *args + ], capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed") + return result.stdout.strip() + + +def normalize_origin_to_https(url: str) -> str: + """Map common SSH origin forms to https for Unity's git URL scheme.""" + if url.startswith("git@github.com:"): + owner_repo = url.split(":", 1)[1] + if owner_repo.endswith(".git"): + owner_repo = owner_repo[:-4] + return f"https://github.com/{owner_repo}.git" + # already https or file: etc. + return url + + +def detect_repo_root(explicit: Optional[str]) -> pathlib.Path: + if explicit: + return pathlib.Path(explicit).resolve() + # Prefer the git toplevel from the script's directory + here = pathlib.Path(__file__).resolve().parent + try: + top = run_git(here, "rev-parse", "--show-toplevel") + return pathlib.Path(top) + except Exception: + return here + + +def detect_branch(repo: pathlib.Path) -> str: + return run_git(repo, "rev-parse", "--abbrev-ref", "HEAD") + + +def detect_origin(repo: pathlib.Path) -> str: + url = run_git(repo, "remote", "get-url", "origin") + return normalize_origin_to_https(url) + + +def find_manifest(explicit: Optional[str]) -> pathlib.Path: + if explicit: + return pathlib.Path(explicit).resolve() + # Walk up from CWD looking for Packages/manifest.json + cur = pathlib.Path.cwd().resolve() + for parent in [cur, *cur.parents]: + candidate = parent / "Packages" / "manifest.json" + if candidate.exists(): + return candidate + raise FileNotFoundError("Could not find Packages/manifest.json from current directory. Use --manifest to specify a path.") + + +def read_json(path: pathlib.Path) -> dict: + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def write_json(path: pathlib.Path, data: dict) -> None: + with path.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") + + +def build_options(repo_root: pathlib.Path, branch: str, origin_https: str): + upstream = "https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge" + # Ensure origin is https + origin = origin_https + # If origin is a local file path or non-https, try to coerce to https github if possible + if origin.startswith("file:"): + # Not meaningful for remote option; keep upstream + origin_remote = upstream + else: + origin_remote = origin + return [ + ("[1] Upstream main", upstream), + ("[2] Remote current branch", f"{origin_remote}?path=/{BRIDGE_SUBPATH}#{branch}"), + ("[3] Local workspace", f"file:{(repo_root / BRIDGE_SUBPATH).as_posix()}"), + ] + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Switch Unity MCP package source") + p.add_argument("--manifest", help="Path to Packages/manifest.json") + p.add_argument("--repo", help="Path to unity-mcp repo root (for local file option)") + p.add_argument("--choice", choices=["1", "2", "3"], help="Pick option non-interactively") + return p.parse_args() + + +def main() -> None: + args = parse_args() + try: + repo_root = detect_repo_root(args.repo) + branch = detect_branch(repo_root) + origin = detect_origin(repo_root) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + options = build_options(repo_root, branch, origin) + + try: + manifest_path = find_manifest(args.manifest) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + print("Select MCP package source by number:") + for label, _ in options: + print(label) + + if args.choice: + choice = args.choice + else: + choice = input("Enter 1-3: ").strip() + + if choice not in {"1", "2", "3"}: + print("Invalid selection.", file=sys.stderr) + sys.exit(1) + + idx = int(choice) - 1 + _, chosen = options[idx] + + data = read_json(manifest_path) + deps = data.get("dependencies", {}) + if PKG_NAME not in deps: + print(f"Error: '{PKG_NAME}' not found in manifest dependencies.", file=sys.stderr) + sys.exit(1) + + print(f"\nUpdating {PKG_NAME} → {chosen}") + deps[PKG_NAME] = chosen + data["dependencies"] = deps + write_json(manifest_path, data) + print(f"Done. Wrote to: {manifest_path}") + print("Tip: In Unity, open Package Manager and Refresh to re-resolve packages.") + + +if __name__ == "__main__": + main() + +#!/usr/bin/env python3 +import json +import os +import sys +import subprocess + +# Defaults for your environment +UNITY_PROJECT = "/Users/davidsarno/ramble" # change if needed +MANIFEST = os.path.join(UNITY_PROJECT, "Packages", "manifest.json") +LOCAL_REPO = "/Users/davidsarno/unity-mcp" # local repo root +PKG_NAME = "com.coplaydev.unity-mcp" + +def get_current_branch(repo_path: str) -> str: + result = subprocess.run( + ["git", "-C", repo_path, "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True + ) + if result.returncode != 0: + print("Error: unable to detect current branch from local repo.", file=sys.stderr) + sys.exit(1) + return result.stdout.strip() + +def build_options(branch: str): + return [ + ("[1] Upstream main", "https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge"), + (f"[2] Remote {branch}", f"https://github.com/dsarno/unity-mcp.git?path=/UnityMcpBridge#{branch}"), + (f"[3] Local {branch}", f"file:{os.path.join(LOCAL_REPO, 'UnityMcpBridge')}"), + ] + +def read_manifest(path: str): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + +def write_manifest(path: str, data: dict): + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") + +def main(): + # Allow overrides via args: python mcp_source.py [manifest.json] [local_repo] + manifest_path = MANIFEST if len(sys.argv) < 2 else sys.argv[1] + repo_path = LOCAL_REPO if len(sys.argv) < 3 else sys.argv[2] + + branch = get_current_branch(repo_path) + options = build_options(branch) + + print("Select MCP package source by number:") + for label, _ in options: + print(label) + choice = input("Enter 1-3: ").strip() + if choice not in {"1", "2", "3"}: + print("Invalid selection.", file=sys.stderr) + sys.exit(1) + + idx = int(choice) - 1 + _, chosen = options[idx] + + data = read_manifest(manifest_path) + deps = data.get("dependencies", {}) + if PKG_NAME not in deps: + print(f"Error: '{PKG_NAME}' not found in manifest dependencies.", file=sys.stderr) + sys.exit(1) + + print(f"\nUpdating {PKG_NAME} → {chosen}") + deps[PKG_NAME] = chosen + data["dependencies"] = deps + write_manifest(manifest_path, data) + print(f"Done. Wrote to: {manifest_path}") + print("Tip: In Unity, open Package Manager and Refresh to re-resolve packages.") + +if __name__ == "__main__": + main() \ No newline at end of file From 5583327a0355b36ff84fb7f983d594a3cf076dbe Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 13 Aug 2025 13:10:47 -0700 Subject: [PATCH 55/69] mcp_source.py: remove duplicate mac-only script; keep cross-platform argparse version (auto-detect manifest/repo; supports interactive/non-interactive) --- mcp_source.py | 73 --------------------------------------------------- 1 file changed, 73 deletions(-) diff --git a/mcp_source.py b/mcp_source.py index 548b2a96..15f2ff4b 100755 --- a/mcp_source.py +++ b/mcp_source.py @@ -164,78 +164,5 @@ def main() -> None: print("Tip: In Unity, open Package Manager and Refresh to re-resolve packages.") -if __name__ == "__main__": - main() - -#!/usr/bin/env python3 -import json -import os -import sys -import subprocess - -# Defaults for your environment -UNITY_PROJECT = "/Users/davidsarno/ramble" # change if needed -MANIFEST = os.path.join(UNITY_PROJECT, "Packages", "manifest.json") -LOCAL_REPO = "/Users/davidsarno/unity-mcp" # local repo root -PKG_NAME = "com.coplaydev.unity-mcp" - -def get_current_branch(repo_path: str) -> str: - result = subprocess.run( - ["git", "-C", repo_path, "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, text=True - ) - if result.returncode != 0: - print("Error: unable to detect current branch from local repo.", file=sys.stderr) - sys.exit(1) - return result.stdout.strip() - -def build_options(branch: str): - return [ - ("[1] Upstream main", "https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge"), - (f"[2] Remote {branch}", f"https://github.com/dsarno/unity-mcp.git?path=/UnityMcpBridge#{branch}"), - (f"[3] Local {branch}", f"file:{os.path.join(LOCAL_REPO, 'UnityMcpBridge')}"), - ] - -def read_manifest(path: str): - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - -def write_manifest(path: str, data: dict): - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - f.write("\n") - -def main(): - # Allow overrides via args: python mcp_source.py [manifest.json] [local_repo] - manifest_path = MANIFEST if len(sys.argv) < 2 else sys.argv[1] - repo_path = LOCAL_REPO if len(sys.argv) < 3 else sys.argv[2] - - branch = get_current_branch(repo_path) - options = build_options(branch) - - print("Select MCP package source by number:") - for label, _ in options: - print(label) - choice = input("Enter 1-3: ").strip() - if choice not in {"1", "2", "3"}: - print("Invalid selection.", file=sys.stderr) - sys.exit(1) - - idx = int(choice) - 1 - _, chosen = options[idx] - - data = read_manifest(manifest_path) - deps = data.get("dependencies", {}) - if PKG_NAME not in deps: - print(f"Error: '{PKG_NAME}' not found in manifest dependencies.", file=sys.stderr) - sys.exit(1) - - print(f"\nUpdating {PKG_NAME} → {chosen}") - deps[PKG_NAME] = chosen - data["dependencies"] = deps - write_manifest(manifest_path, data) - print(f"Done. Wrote to: {manifest_path}") - print("Tip: In Unity, open Package Manager and Refresh to re-resolve packages.") - if __name__ == "__main__": main() \ No newline at end of file From 4e1b905ea02e9ef3a4e010a2b7c4f24752c00c69 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 13 Aug 2025 14:02:19 -0700 Subject: [PATCH 56/69] chore: bump version to 2.1.0; Windows uv resolver improvements; preserve existing uv command; Claude unregister UI fix; .ps1 handling; add generic mcp_source.py --- UnityMcpBridge/Editor/Helpers/ExecPath.cs | 10 ++- .../Editor/Helpers/ServerInstaller.cs | 79 ++++++++++--------- .../Editor/Windows/UnityMcpEditorWindow.cs | 19 ++++- UnityMcpBridge/package.json | 2 +- mcp_source.py | 2 +- 5 files changed, 69 insertions(+), 43 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ExecPath.cs b/UnityMcpBridge/Editor/Helpers/ExecPath.cs index 99dcf5a7..e3a03b43 100644 --- a/UnityMcpBridge/Editor/Helpers/ExecPath.cs +++ b/UnityMcpBridge/Editor/Helpers/ExecPath.cs @@ -176,10 +176,16 @@ internal static bool TryRun( stderr = string.Empty; try { + // Handle PowerShell scripts on Windows by invoking through powershell.exe + bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); + var psi = new ProcessStartInfo { - FileName = file, - Arguments = args, + FileName = isPs1 ? "powershell.exe" : file, + Arguments = isPs1 + ? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim() + : args, WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir, UseShellExecute = false, RedirectStandardOutput = true, diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index aa845898..a2c28fe5 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -2,7 +2,6 @@ using System.IO; using System.Runtime.InteropServices; using System.Text; -using System.Reflection; using UnityEditor; using UnityEngine; @@ -70,21 +69,19 @@ private static string GetSaveLocation() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "AppData", - "Local", - "Programs", - RootFolder - ); + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); + return Path.Combine(localAppData, "Programs", RootFolder); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "bin", - RootFolder - ); + var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + if (string.IsNullOrEmpty(xdg)) + { + xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, + ".local", "share"); + } + return Path.Combine(xdg, RootFolder); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { @@ -273,12 +270,41 @@ internal static string FindUvPath() string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty; string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + string programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) ?? string.Empty; // optional fallback + + // Fast path: resolve from PATH first + try + { + var wherePsi = new System.Diagnostics.ProcessStartInfo + { + FileName = "where", + Arguments = "uv.exe", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var wp = System.Diagnostics.Process.Start(wherePsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(1500); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + string path = line.Trim(); + if (File.Exists(path) && ValidateUvBinary(path)) return path; + } + } + } + catch { } candidates = new[] { // Preferred: WinGet Links shims (stable entrypoints) Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"), Path.Combine(programFiles, "WinGet", "Links", "uv.exe"), + // Optional low-priority fallback for atypical images + Path.Combine(programData, "Microsoft", "WinGet", "Links", "uv.exe"), // Common per-user installs Path.Combine(localAppData, @"Programs\Python\Python313\Scripts\uv.exe"), @@ -325,33 +351,10 @@ internal static string FindUvPath() catch { /* ignore */ } } - // Use platform-appropriate which/where to resolve from PATH + // Use platform-appropriate which/where to resolve from PATH (non-Windows handled here; Windows tried earlier) try { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var wherePsi = new System.Diagnostics.ProcessStartInfo - { - FileName = "where", - Arguments = "uv.exe", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var wp = System.Diagnostics.Process.Start(wherePsi); - string output = wp.StandardOutput.ReadToEnd().Trim(); - wp.WaitForExit(3000); - if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output)) - { - foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) - { - string path = line.Trim(); - if (File.Exists(path) && ValidateUvBinary(path)) return path; - } - } - } - else + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var whichPsi = new System.Diagnostics.ProcessStartInfo { diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index ab8ab32c..234a3a09 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1064,6 +1064,14 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC // Merge without replacing the existing command if (mcpClient?.mcpType == McpTypes.VSCode) { + if (existingConfig.servers == null) + { + existingConfig.servers = new Newtonsoft.Json.Linq.JObject(); + } + if (existingConfig.servers.unityMCP == null) + { + existingConfig.servers.unityMCP = new Newtonsoft.Json.Linq.JObject(); + } existingConfig.servers.unityMCP.args = JsonConvert.DeserializeObject( JsonConvert.SerializeObject(unityMCPConfig.args) @@ -1071,6 +1079,14 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC } else { + if (existingConfig.mcpServers == null) + { + existingConfig.mcpServers = new Newtonsoft.Json.Linq.JObject(); + } + if (existingConfig.mcpServers.unityMCP == null) + { + existingConfig.mcpServers.unityMCP = new Newtonsoft.Json.Linq.JObject(); + } existingConfig.mcpServers.unityMCP.args = JsonConvert.DeserializeObject( JsonConvert.SerializeObject(unityMCPConfig.args) @@ -1617,7 +1633,8 @@ private void UnregisterWithClaudeCode() UnityEngine.Debug.Log($"Successfully removed MCP server: {serverName}"); break; } - else if (!stderr.Contains("No MCP server found")) + else if (!string.IsNullOrEmpty(stderr) && + !stderr.Contains("No MCP server found", StringComparison.OrdinalIgnoreCase)) { // If it's not a "not found" error, log it and stop trying UnityEngine.Debug.LogWarning($"Error removing {serverName}: {stderr}"); diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index ba4add49..445f448b 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "2.0.2", + "version": "2.1.0", "displayName": "Unity MCP Bridge", "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", "unity": "2020.3", diff --git a/mcp_source.py b/mcp_source.py index 15f2ff4b..535dbaef 100755 --- a/mcp_source.py +++ b/mcp_source.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Generic helper to switch the Unity MCP package source in a Unity project's -Packages/manifest.json without embedding any personal paths. +Packages/manifest.json. This is useful for switching between upstream and local repos while working on the MCP. Usage: python mcp_source.py [--manifest /abs/path/to/manifest.json] [--repo /abs/path/to/unity-mcp] [--choice 1|2|3] From 370a36044dc580e5283e2f051955b4ef69fa9930 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 13 Aug 2025 14:23:52 -0700 Subject: [PATCH 57/69] docs: update README with client-specific config flows and mcp_source.py documentation --- README-DEV.md | 24 +++++++++++++++++++++++- README.md | 26 ++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/README-DEV.md b/README-DEV.md index 98dafae6..f6bb942d 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -36,6 +36,8 @@ Deploys your development code to the actual installation locations for testing. 3. Enter server path (or use default: `%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src`) 4. Enter backup location (or use default: `%USERPROFILE%\Desktop\unity-mcp-backup`) +**Note:** Dev deploy skips `.venv`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.git`; reduces churn and avoids copying virtualenvs. + ### `restore-dev.bat` Restores original files from backup. @@ -73,6 +75,23 @@ Note: In recent builds, the Python server sources are also bundled inside the pa 5. **Restore** original files when done using `restore-dev.bat` +## Switching MCP package sources quickly + +Use `mcp_source.py` to quickly switch between different Unity MCP package sources: + +**Usage:** +```bash +python mcp_source.py [--manifest /path/to/manifest.json] [--repo /path/to/unity-mcp] [--choice 1|2|3] +``` + +**Options:** +- **1** Upstream main (CoplayDev/unity-mcp) +- **2** Remote current branch (origin + branch) +- **3** Local workspace (file: UnityMcpBridge) + +After switching, open Package Manager and Refresh to re-resolve packages. + + ## Troubleshooting ### "Path not found" errors running the .bat file @@ -88,4 +107,7 @@ Note: In recent builds, the Python server sources are also bundled inside the pa ### "Backup not found" errors - Run `deploy-dev.bat` first to create initial backup - Check backup directory permissions -- Verify backup directory path is correct \ No newline at end of file +- Verify backup directory path is correct + +### Windows uv path issues +- On Windows, when testing GUI clients, prefer the WinGet Links `uv.exe`; if multiple `uv.exe` exist, use "Choose UV Install Location" to pin the Links shim. \ No newline at end of file diff --git a/README.md b/README.md index 673837b8..17d63c86 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,13 @@ Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installe 1. In Unity, go to `Window > Unity MCP`. 2. Click `Auto-Setup`. -3. Look for a green status indicator 🟢 and "Connected ✓". *(This attempts to modify the MCP Client\'s config file automatically)*. +3. Look for a green status indicator 🟢 and "Connected ✓". *(This attempts to modify the MCP Client\'s config file automatically).* + +Client-specific notes + +- **VSCode**: uses `Code/User/mcp.json` with top-level `servers.unityMCP` and `"type": "stdio"`. On Windows, Unity MCP writes an absolute `uv.exe` (prefers WinGet Links shim) to avoid PATH issues. +- **Cursor / Windsurf**: if `uv` is missing, the Unity MCP window shows "uv Not Found" with a quick [HELP] link and a "Choose UV Install Location" button. +- **Claude Code**: if `claude` isn't found, the window shows "Claude Not Found" with [HELP] and a "Choose Claude Location" button. Unregister now updates the UI immediately. **Option B: Manual Configuration** @@ -137,7 +143,23 @@ If Auto-Setup fails or you use a different client: 2. **Edit the file** to add/update the `mcpServers` section, using the *exact* paths from Step 1.
-Click for OS-Specific JSON Configuration Snippets... +Click for Client-Specific JSON Configuration Snippets... + +**VSCode (all OS)** + +```json +{ + "servers": { + "unityMCP": { + "command": "uv", + "args": ["--directory","/UnityMcpServer/src","run","server.py"], + "type": "stdio" + } + } +} +``` + +On Windows, set `command` to the absolute shim, e.g. `C:\\Users\\YOU\\AppData\\Local\\Microsoft\\WinGet\\Links\\uv.exe`. **Windows:** From f8c76db9ca84a250befc7c7e8acefc24aba7d7c4 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 13 Aug 2025 14:31:10 -0700 Subject: [PATCH 58/69] Fix Unity Package Manager Git URL format in mcp_source.py --- mcp_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp_source.py b/mcp_source.py index 535dbaef..1cd708e3 100755 --- a/mcp_source.py +++ b/mcp_source.py @@ -92,7 +92,7 @@ def write_json(path: pathlib.Path, data: dict) -> None: def build_options(repo_root: pathlib.Path, branch: str, origin_https: str): - upstream = "https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge" + upstream = "git+https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge" # Ensure origin is https origin = origin_https # If origin is a local file path or non-https, try to coerce to https github if possible From 6e22721d3a856963e0d7e80517923412021ec3ff Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 13 Aug 2025 17:46:07 -0700 Subject: [PATCH 59/69] feat: preserve existing client config; prefer installed server path; add ResolveServerSrc; block PackageCache unless dev override; canonical uv args --- .../Editor/Windows/UnityMcpEditorWindow.cs | 290 +++++++++--------- 1 file changed, 152 insertions(+), 138 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 234a3a09..2962c7b2 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -931,24 +931,41 @@ private void ToggleUnityBridge() Repaint(); } - private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) + private static bool IsValidUv(string path) + { + return !string.IsNullOrEmpty(path) + && System.IO.Path.IsPathRooted(path) + && System.IO.File.Exists(path); + } + + private static string ExtractDirectoryArg(string[] args) + { + if (args == null) return null; + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + return null; + } + + private static bool ArgsEqual(string[] a, string[] b) + { + if (a == null || b == null) return a == b; + if (a.Length != b.Length) return false; + for (int i = 0; i < a.Length; i++) + { + if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) return false; + } + return true; + } + + private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) { - string uvPath = FindUvPath(); - if (uvPath == null) - { - return "UV package manager not found. Please install UV first."; - } - - // Create configuration object for unityMCP - McpConfigServer unityMCPConfig = new() - { - command = uvPath, - args = new[] { "--directory", pythonDir, "run", "server.py" }, - }; - if (mcpClient?.mcpType == McpTypes.VSCode) - { - unityMCPConfig.type = "stdio"; - } + // 0) Respect explicit lock (hidden pref or UI toggle) + try { if (UnityEditor.EditorPrefs.GetBool("UnityMCP.LockCursorConfig", false)) return "Skipped (locked)"; } catch { } JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; @@ -989,123 +1006,83 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC existingConfig = new Newtonsoft.Json.Linq.JObject(); } - // Handle different client types with a switch statement - //Comments: Interestingly, VSCode has mcp.servers.unityMCP while others have mcpServers.unityMCP, which is why we need to prevent this - switch (mcpClient?.mcpType) - { - case McpTypes.VSCode: - // VSCode-specific configuration (top-level "servers") - if (existingConfig.servers == null) - { - existingConfig.servers = new Newtonsoft.Json.Linq.JObject(); - } + // Determine existing entry references (command/args) + string existingCommand = null; + string[] existingArgs = null; + bool isVSCode = (mcpClient?.mcpType == McpTypes.VSCode); + try + { + if (isVSCode) + { + existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString(); + existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject(); + } + else + { + existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString(); + existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject(); + } + } + catch { } - // Add/update UnityMCP server in VSCode mcp.json - existingConfig.servers.unityMCP = - JsonConvert.DeserializeObject( - JsonConvert.SerializeObject(unityMCPConfig) - ); - break; + // 1) Start from existing, only fill gaps + string uvPath = IsValidUv(existingCommand) ? existingCommand : FindUvPath(); + if (uvPath == null) return "UV package manager not found. Please install UV first."; - default: - // Standard MCP configuration (Claude Desktop, Cursor, etc.) - // Ensure mcpServers object exists - if (existingConfig.mcpServers == null) - { - existingConfig.mcpServers = new Newtonsoft.Json.Linq.JObject(); - } + string serverSrc = ExtractDirectoryArg(existingArgs); + bool serverValid = !string.IsNullOrEmpty(serverSrc) + && System.IO.File.Exists(System.IO.Path.Combine(serverSrc, "server.py")); + if (!serverValid) + { + serverSrc = ResolveServerSrc(); + } - // Add/update UnityMCP server in standard MCP settings - existingConfig.mcpServers.unityMCP = - JsonConvert.DeserializeObject( - JsonConvert.SerializeObject(unityMCPConfig) - ); - break; - } + // Hard-block PackageCache on Windows unless dev override is set + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 + && !UnityEditor.EditorPrefs.GetBool("UnityMCP.UseEmbeddedServer", false)) + { + serverSrc = ServerInstaller.GetServerPath(); + } - // If config already has a working absolute uv path, avoid rewriting it on refresh - try - { - if (mcpClient?.mcpType != McpTypes.ClaudeCode) - { - // Inspect existing command for stability (Windows absolute path that exists) - string existingCommand = null; - if (mcpClient?.mcpType == McpTypes.VSCode) - { - existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString(); - } - else - { - existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString(); - } + // 2) Canonical args order + var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; - if (!string.IsNullOrEmpty(existingCommand)) - { - bool keep = false; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Consider absolute, existing paths as stable; prefer WinGet Links - if (Path.IsPathRooted(existingCommand) && File.Exists(existingCommand)) - { - keep = true; - } - } - else - { - // On Unix, keep absolute existing path as well - if (Path.IsPathRooted(existingCommand) && File.Exists(existingCommand)) - { - keep = true; - } - } + // 3) Only write if changed + bool changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal) + || !ArgsEqual(existingArgs, newArgs); + if (!changed) + { + return "Configured successfully"; // nothing to do + } - if (keep) - { - // Merge without replacing the existing command - if (mcpClient?.mcpType == McpTypes.VSCode) - { - if (existingConfig.servers == null) - { - existingConfig.servers = new Newtonsoft.Json.Linq.JObject(); - } - if (existingConfig.servers.unityMCP == null) - { - existingConfig.servers.unityMCP = new Newtonsoft.Json.Linq.JObject(); - } - existingConfig.servers.unityMCP.args = - JsonConvert.DeserializeObject( - JsonConvert.SerializeObject(unityMCPConfig.args) - ); - } - else - { - if (existingConfig.mcpServers == null) - { - existingConfig.mcpServers = new Newtonsoft.Json.Linq.JObject(); - } - if (existingConfig.mcpServers.unityMCP == null) - { - existingConfig.mcpServers.unityMCP = new Newtonsoft.Json.Linq.JObject(); - } - existingConfig.mcpServers.unityMCP.args = - JsonConvert.DeserializeObject( - JsonConvert.SerializeObject(unityMCPConfig.args) - ); - } - string mergedKeep = JsonConvert.SerializeObject(existingConfig, jsonSettings); - File.WriteAllText(configPath, mergedKeep); - return "Configured successfully"; - } - } - } - } - catch { /* fall back to normal write */ } + // 4) Ensure containers exist and write back minimal changes + if (isVSCode) + { + if (existingConfig.servers == null) existingConfig.servers = new Newtonsoft.Json.Linq.JObject(); + if (existingConfig.servers.unityMCP == null) existingConfig.servers.unityMCP = new Newtonsoft.Json.Linq.JObject(); + existingConfig.servers.unityMCP.command = uvPath; + existingConfig.servers.unityMCP.args = Newtonsoft.Json.Linq.JArray.FromObject(newArgs); + existingConfig.servers.unityMCP.type = "stdio"; + } + else + { + if (existingConfig.mcpServers == null) existingConfig.mcpServers = new Newtonsoft.Json.Linq.JObject(); + if (existingConfig.mcpServers.unityMCP == null) existingConfig.mcpServers.unityMCP = new Newtonsoft.Json.Linq.JObject(); + existingConfig.mcpServers.unityMCP.command = uvPath; + existingConfig.mcpServers.unityMCP.args = Newtonsoft.Json.Linq.JArray.FromObject(newArgs); + } - // Write the merged configuration back to file - string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); - File.WriteAllText(configPath, mergedJson); + string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); + File.WriteAllText(configPath, mergedJson); + try + { + if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("UnityMCP.UvPath", uvPath); + UnityEditor.EditorPrefs.SetString("UnityMCP.ServerSrc", serverSrc); + } + catch { } - return "Configured successfully"; + return "Configured successfully"; } private void ShowManualConfigurationInstructions( @@ -1182,9 +1159,38 @@ private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient); } - private string FindPackagePythonDirectory() + private static string ResolveServerSrc() + { + try + { + string remembered = UnityEditor.EditorPrefs.GetString("UnityMCP.ServerSrc", string.Empty); + if (!string.IsNullOrEmpty(remembered) && File.Exists(Path.Combine(remembered, "server.py"))) + { + return remembered; + } + + ServerInstaller.EnsureServerInstalled(); + string installed = ServerInstaller.GetServerPath(); + if (File.Exists(Path.Combine(installed, "server.py"))) + { + return installed; + } + + bool useEmbedded = UnityEditor.EditorPrefs.GetBool("UnityMCP.UseEmbeddedServer", false); + if (useEmbedded && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded) + && File.Exists(Path.Combine(embedded, "server.py"))) + { + return embedded; + } + + return installed; + } + catch { return ServerInstaller.GetServerPath(); } + } + + private string FindPackagePythonDirectory() { - string pythonDir = ServerInstaller.GetServerPath(); + string pythonDir = ResolveServerSrc(); try { @@ -1211,17 +1217,25 @@ private string FindPackagePythonDirectory() } } - // Resolve via shared helper (handles local registry and older fallback) - if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) - { - return embedded; - } + // Resolve via shared helper (handles local registry and older fallback) only if dev override on + if (UnityEditor.EditorPrefs.GetBool("UnityMCP.UseEmbeddedServer", false)) + { + if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) + { + return embedded; + } + } - // If still not found, return the placeholder path - if (debugLogsEnabled) - { - UnityEngine.Debug.LogWarning("Could not find Python directory, using placeholder path"); - } + // Log only if the resolved path does not actually contain server.py + if (debugLogsEnabled) + { + bool hasServer = false; + try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { } + if (!hasServer) + { + UnityEngine.Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); + } + } } catch (Exception e) { From 6e59b8fe8dc343c99f38ee9d3cb6f0593bf8bc1f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 13 Aug 2025 18:13:25 -0700 Subject: [PATCH 60/69] fix: linux XDG config paths; prefer installed server; robust cursor detection; atomic writes; uv validation; WinGet Links ordering --- CursorHelp.md | 85 +++++++++++ UnityMcpBridge/Editor/Data/McpClients.cs | 6 +- .../Editor/Helpers/ServerInstaller.cs | 15 +- UnityMcpBridge/Editor/UnityMcpBridge.cs | 137 +++++++++++++++++- .../Editor/Windows/UnityMcpEditorWindow.cs | 69 +++++++-- .../UnityMcpServer~/src/unity_connection.py | 104 +++++++------ UnityMcpBridge/UnityMcpServer~/src/uv.lock | 53 ++++++- claude-chunk.md | 51 +++++++ package-lock.json | 6 + package.json | 1 + 10 files changed, 443 insertions(+), 84 deletions(-) create mode 100644 CursorHelp.md create mode 100644 claude-chunk.md create mode 100644 package-lock.json create mode 100644 package.json diff --git a/CursorHelp.md b/CursorHelp.md new file mode 100644 index 00000000..0b8d6f01 --- /dev/null +++ b/CursorHelp.md @@ -0,0 +1,85 @@ +### Cursor/VSCode/Windsurf: UV path issue on Windows (diagnosis and fix) + +#### The issue +- Some Windows machines have multiple `uv.exe` locations. Our auto-config sometimes picked a less stable path, causing the MCP client to fail to launch the Unity MCP Server or for the path to be auto-rewritten on repaint/restart. + +#### Typical symptoms +- Cursor shows the UnityMCP server but never connects or reports it “can’t start.” +- Your `%USERPROFILE%\\.cursor\\mcp.json` flips back to a different `command` path when Unity or the Unity MCP window refreshes. + +#### Real-world example +- Wrong/fragile path (auto-picked): + - `C:\Users\mrken.local\bin\uv.exe` (malformed, not standard) + - `C:\Users\mrken\AppData\Local\Microsoft\WinGet\Packages\astral-sh.uv_Microsoft.Winget.Source_8wekyb3d8bbwe\uv.exe` +- Correct/stable path (works with Cursor): + - `C:\Users\mrken\AppData\Local\Microsoft\WinGet\Links\uv.exe` + +#### Quick fix (recommended) +1) In Unity: `Window > Unity MCP` → select your MCP client (Cursor or Windsurf) +2) If you see “uv Not Found,” click “Choose UV Install Location” and browse to: + - `C:\Users\\AppData\Local\Microsoft\WinGet\Links\uv.exe` +3) If uv is already found but wrong, still click “Choose UV Install Location” and select the `Links\uv.exe` path above. This saves a persistent override. +4) Click “Auto Configure” (or re-open the client) and restart Cursor. + +This sets an override stored in the Editor (key: `UnityMCP.UvPath`) so UnityMCP won’t auto-rewrite the config back to a different `uv.exe` later. + +#### Verify the fix +- Confirm global Cursor config is at: `%USERPROFILE%\\.cursor\\mcp.json` +- You should see something like: + +```json +{ + "mcpServers": { + "unityMCP": { + "command": "C:\\Users\\YOU\\AppData\\Local\\Microsoft\\WinGet\\Links\\uv.exe", + "args": [ + "--directory", + "C:\\Users\\YOU\\AppData\\Local\\Programs\\UnityMCP\\UnityMcpServer\\src", + "run", + "server.py" + ] + } + } +} +``` + +- Manually run the same command in PowerShell to confirm it launches: + +```powershell +"C:\Users\YOU\AppData\Local\Microsoft\WinGet\Links\uv.exe" --directory "C:\Users\YOU\AppData\Local\Programs\UnityMCP\UnityMcpServer\src" run server.py +``` + +If that runs without error, restart Cursor and it should connect. + +#### Why this happens +- On Windows, multiple `uv.exe` can exist (WinGet Packages path, a WinGet Links shim, Python Scripts, etc.). The Links shim is the most stable target for GUI apps to launch. +- Prior versions of the auto-config could pick the first found path and re-write config on refresh. Choosing a path via the MCP window pins a known‑good absolute path and prevents auto-rewrites. + +#### Extra notes +- Restart Cursor after changing `mcp.json`; it doesn’t always hot-reload that file. +- If you also have a project-scoped `.cursor\\mcp.json` in your Unity project folder, that file overrides the global one. + + +### Why pin the WinGet Links shim (and not the Packages path) + +- Windows often has multiple `uv.exe` installs and GUI clients (Cursor/Windsurf/VSCode) may launch with a reduced `PATH`. Using an absolute path is safer than `"command": "uv"`. +- WinGet publishes stable launch shims in these locations: + - User scope: `%LOCALAPPDATA%\Microsoft\WinGet\Links\uv.exe` + - Machine scope: `C:\Program Files\WinGet\Links\uv.exe` + These shims survive upgrades and are intended as the portable entrypoints. See the WinGet notes: [discussion](https://github.com/microsoft/winget-pkgs/discussions/184459) • [how to find installs](https://superuser.com/questions/1739292/how-to-know-where-winget-installed-a-program) +- The `Packages` root is where payloads live and can change across updates, so avoid pointing your config at it. + +Recommended practice + +- Prefer the WinGet Links shim paths above. If present, select one via “Choose UV Install Location”. +- If the unity window keeps rewriting to a different `uv.exe`, pick the Links shim again; Unity MCP saves a pinned override and will stop auto-rewrites. +- If neither Links path exists, a reasonable fallback is `~/.local/bin/uv.exe` (uv tools bin) or a Scoop shim, but Links is preferred for stability. + +References + +- WinGet portable Links: [GitHub discussion](https://github.com/microsoft/winget-pkgs/discussions/184459) +- WinGet install locations: [Super User](https://superuser.com/questions/1739292/how-to-know-where-winget-installed-a-program) +- GUI client PATH caveats (Cursor): [Cursor community thread](https://forum.cursor.com/t/mcp-feature-client-closed-fix/54651?page=4) +- uv tools install location (`~/.local/bin`): [Astral docs](https://docs.astral.sh/uv/concepts/tools/) + + diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index 9d9cbae5..e8c6fadf 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -71,8 +71,7 @@ public class McpClients ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", + ".config", "Claude", "claude_desktop_config.json" ), @@ -91,8 +90,7 @@ public class McpClients ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", + ".config", "Code", "User", "mcp.json" diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index a2c28fe5..ddb4a506 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -88,7 +88,7 @@ private static string GetSaveLocation() // Use Application Support for a stable, user-writable location return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "UnityMCP" + RootFolder ); } throw new Exception("Unsupported operating system."); @@ -126,6 +126,7 @@ private static bool TryGetEmbeddedServerSource(out string srcPath) return ServerPathResolver.TryFindEmbeddedServerSource(out srcPath); } + private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" }; private static void CopyDirectoryRecursive(string sourceDir, string destinationDir) { Directory.CreateDirectory(destinationDir); @@ -140,8 +141,15 @@ private static void CopyDirectoryRecursive(string sourceDir, string destinationD foreach (string dirPath in Directory.GetDirectories(sourceDir)) { string dirName = Path.GetFileName(dirPath); + foreach (var skip in _skipDirs) + { + if (dirName.Equals(skip, StringComparison.OrdinalIgnoreCase)) + goto NextDir; + } + try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { } string destSubDir = Path.Combine(destinationDir, dirName); CopyDirectoryRecursive(dirPath, destSubDir); + NextDir: ; } } @@ -301,10 +309,11 @@ internal static string FindUvPath() candidates = new[] { // Preferred: WinGet Links shims (stable entrypoints) + // Per-user shim, then machine-wide shim Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"), + Path.Combine(programData, "Microsoft", "WinGet", "Links", "uv.exe"), + // ProgramFiles Links is uncommon; keep as low-priority fallback Path.Combine(programFiles, "WinGet", "Links", "uv.exe"), - // Optional low-priority fallback for atypical images - Path.Combine(programData, "Microsoft", "WinGet", "Links", "uv.exe"), // Common per-user installs Path.Combine(localAppData, @"Programs\Python\Python313\Scripts\uv.exe"), diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index b7e8ef0e..fc06dd28 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -395,22 +395,80 @@ private static async Task HandleClientAsync(TcpClient client) using (client) using (NetworkStream stream = client.GetStream()) { + const int MaxMessageBytes = 64 * 1024 * 1024; // 64 MB safety cap byte[] buffer = new byte[8192]; while (isRunning) { try { - int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); - if (bytesRead == 0) + // Read message with optional length prefix (8-byte big-endian) + bool usedFraming = false; + string commandText = null; + + // First, attempt to read an 8-byte header + byte[] header = new byte[8]; + int headerFilled = 0; + while (headerFilled < 8) { - break; // Client disconnected + int r = await stream.ReadAsync(header, headerFilled, 8 - headerFilled); + if (r == 0) + { + // Disconnected + return; + } + headerFilled += r; + } + + // Interpret header as big-endian payload length, with plausibility check + ulong payloadLen = ReadUInt64BigEndian(header); + if (payloadLen > 0 && payloadLen <= (ulong)MaxMessageBytes) + { + // Framed message path + usedFraming = true; + byte[] payload = await ReadExactAsync(stream, (int)payloadLen); + commandText = System.Text.Encoding.UTF8.GetString(payload); + } + else + { + // Legacy path: treat header bytes as the beginning of a JSON/plain message and read until we have a full JSON + usedFraming = false; + using var ms = new MemoryStream(); + ms.Write(header, 0, header.Length); + + // Read available data in chunks; stop when we have valid JSON or ping, or when no more data available for now + while (true) + { + // If we already have enough text, try to interpret + string currentText = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + string trimmed = currentText.Trim(); + if (trimmed == "ping") + { + commandText = trimmed; + break; + } + if (IsValidJson(trimmed)) + { + commandText = trimmed; + break; + } + + // Read next chunk + int r = await stream.ReadAsync(buffer, 0, buffer.Length); + if (r == 0) + { + // Disconnected mid-message; fall back to whatever we have + commandText = currentText; + break; + } + ms.Write(buffer, 0, r); + + if (ms.Length > MaxMessageBytes) + { + throw new IOException($"Incoming message exceeded {MaxMessageBytes} bytes cap"); + } + } } - string commandText = System.Text.Encoding.UTF8.GetString( - buffer, - 0, - bytesRead - ); string commandId = Guid.NewGuid().ToString(); TaskCompletionSource tcs = new(); @@ -422,6 +480,14 @@ private static async Task HandleClientAsync(TcpClient client) /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); + + if (usedFraming) + { + // Mirror framing for response + byte[] outHeader = new byte[8]; + WriteUInt64BigEndian(outHeader, (ulong)pingResponseBytes.Length); + await stream.WriteAsync(outHeader, 0, outHeader.Length); + } await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length); continue; } @@ -433,6 +499,12 @@ private static async Task HandleClientAsync(TcpClient client) string response = await tcs.Task; byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); + if (usedFraming) + { + byte[] outHeader = new byte[8]; + WriteUInt64BigEndian(outHeader, (ulong)responseBytes.Length); + await stream.WriteAsync(outHeader, 0, outHeader.Length); + } await stream.WriteAsync(responseBytes, 0, responseBytes.Length); } catch (Exception ex) @@ -444,6 +516,55 @@ private static async Task HandleClientAsync(TcpClient client) } } + // Read exactly count bytes or throw if stream closes prematurely + private static async Task ReadExactAsync(NetworkStream stream, int count) + { + byte[] data = new byte[count]; + int offset = 0; + while (offset < count) + { + int r = await stream.ReadAsync(data, offset, count - offset); + if (r == 0) + { + throw new IOException("Connection closed before reading expected bytes"); + } + offset += r; + } + return data; + } + + private static ulong ReadUInt64BigEndian(byte[] buffer) + { + if (buffer == null || buffer.Length < 8) + { + return 0UL; + } + return ((ulong)buffer[0] << 56) + | ((ulong)buffer[1] << 48) + | ((ulong)buffer[2] << 40) + | ((ulong)buffer[3] << 32) + | ((ulong)buffer[4] << 24) + | ((ulong)buffer[5] << 16) + | ((ulong)buffer[6] << 8) + | buffer[7]; + } + + private static void WriteUInt64BigEndian(byte[] dest, ulong value) + { + if (dest == null || dest.Length < 8) + { + throw new ArgumentException("Destination buffer too small for UInt64"); + } + dest[0] = (byte)(value >> 56); + dest[1] = (byte)(value >> 48); + dest[2] = (byte)(value >> 40); + dest[3] = (byte)(value >> 32); + dest[4] = (byte)(value >> 24); + dest[5] = (byte)(value >> 16); + dest[6] = (byte)(value >> 8); + dest[7] = (byte)(value); + } + private static void ProcessCommands() { List processedIds = new(); diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 2962c7b2..9e42d7ff 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -630,15 +630,29 @@ private static bool IsCursorConfigured(string pythonDir) if (unity == null) return false; var args = unity.args; if (args == null) return false; - foreach (var a in args) + // Prefer exact extraction of the --directory value and compare normalized paths + string[] strArgs = ((System.Collections.Generic.IEnumerable)args) + .Select(x => x?.ToString() ?? string.Empty) + .ToArray(); + string dir = ExtractDirectoryArg(strArgs); + if (string.IsNullOrEmpty(dir)) return false; + return PathsEqual(dir, pythonDir); + } + catch { return false; } + } + + private static bool PathsEqual(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; + try + { + string na = System.IO.Path.GetFullPath(a.Trim()); + string nb = System.IO.Path.GetFullPath(b.Trim()); + if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) { - string s = (string)a; - if (!string.IsNullOrEmpty(s) && s.Contains(pythonDir, StringComparison.Ordinal)) - { - return true; - } + return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); } - return false; + return string.Equals(na, nb, StringComparison.Ordinal); } catch { return false; } } @@ -883,7 +897,7 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) unityMCP = new { command = uvPath, - args = new[] { "--directory", pythonDir, "run", "server.py" } + args = new[] { "run", "--directory", pythonDir, "server.py" } } } } @@ -938,6 +952,30 @@ private static bool IsValidUv(string path) && System.IO.File.Exists(path); } + private static bool ValidateUvBinarySafe(string path) + { + try + { + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return false; + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = path, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p == null) return false; + if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; } + if (p.ExitCode != 0) return false; + string output = p.StandardOutput.ReadToEnd().Trim(); + return output.StartsWith("uv "); + } + catch { return false; } + } + private static string ExtractDirectoryArg(string[] args) { if (args == null) return null; @@ -1026,7 +1064,7 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC catch { } // 1) Start from existing, only fill gaps - string uvPath = IsValidUv(existingCommand) ? existingCommand : FindUvPath(); + string uvPath = (ValidateUvBinarySafe(existingCommand) ? existingCommand : FindUvPath()); if (uvPath == null) return "UV package manager not found. Please install UV first."; string serverSrc = ExtractDirectoryArg(existingArgs); @@ -1074,7 +1112,12 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC } string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); - File.WriteAllText(configPath, mergedJson); + string tmp = configPath + ".tmp"; + System.IO.File.WriteAllText(tmp, mergedJson, System.Text.Encoding.UTF8); + if (System.IO.File.Exists(configPath)) + System.IO.File.Replace(tmp, configPath, null); + else + System.IO.File.Move(tmp, configPath); try { if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("UnityMCP.UvPath", uvPath); @@ -1124,7 +1167,7 @@ private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient unityMCP = new { command = uvPathManual, - args = new[] { "--directory", pythonDir, "run", "server.py" }, + args = new[] { "run", "--directory", pythonDir, "server.py" }, type = "stdio" } } @@ -1148,7 +1191,7 @@ private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient unityMCP = new McpConfigServer { command = uvPath, - args = new[] { "--directory", pythonDir, "run", "server.py" }, + args = new[] { "run", "--directory", pythonDir, "server.py" }, }, }, }; @@ -1368,7 +1411,7 @@ McpClient mcpClient unityMCP = new McpConfigServer { command = uvPath, - args = new[] { "--directory", pythonDir, "run", "server.py" }, + args = new[] { "run", "--directory", pythonDir, "server.py" }, }, }, }; diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index 9bad736d..bf030d09 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -9,6 +9,7 @@ from typing import Dict, Any from config import config from port_discovery import PortDiscovery +import struct # Configure logging using settings from config logging.basicConfig( @@ -53,60 +54,52 @@ def disconnect(self): finally: self.sock = None - def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: - """Receive a complete response from Unity, handling chunked data.""" - chunks = [] - sock.settimeout(config.connection_timeout) # Use timeout from config + def receive_full_response(self, sock) -> bytes: + """Receive a complete response from Unity using 8-byte length-prefixed framing, with legacy fallback.""" + sock.settimeout(config.connection_timeout) + # Try framed first try: - while True: - chunk = sock.recv(buffer_size) - if not chunk: - if not chunks: - raise Exception("Connection closed before receiving data") - break - chunks.append(chunk) - - # Process the data received so far + header = self._read_exact(sock, 8) + (payload_len,) = struct.unpack('>Q', header) + if 0 < payload_len <= (64 * 1024 * 1024): + return self._read_exact(sock, payload_len) + # Implausible length -> treat as legacy stream; fall through + legacy_prefix = header + except Exception: + # Could not read header — treat as legacy + legacy_prefix = b'' + + # Legacy: read until parses as JSON or times out + chunks: list[bytes] = [] + if legacy_prefix: + chunks.append(legacy_prefix) + while True: + chunk = sock.recv(config.buffer_size) + if not chunk: data = b''.join(chunks) - decoded_data = data.decode('utf-8') - - # Check if we've received a complete response - try: - # Special case for ping-pong - if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): - logger.debug("Received ping response") - return data - - # Handle escaped quotes in the content - if '"content":' in decoded_data: - # Find the content field and its value - content_start = decoded_data.find('"content":') + 9 - content_end = decoded_data.rfind('"', content_start) - if content_end > content_start: - # Replace escaped quotes in content with regular quotes - content = decoded_data[content_start:content_end] - content = content.replace('\\"', '"') - decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:] - - # Validate JSON format - json.loads(decoded_data) - - # If we get here, we have valid JSON - logger.info(f"Received complete response ({len(data)} bytes)") + if not data: + raise Exception("Connection closed before receiving data") + return data + chunks.append(chunk) + data = b''.join(chunks) + try: + if data.strip() == b'ping': return data - except json.JSONDecodeError: - # We haven't received a complete valid JSON response yet - continue - except Exception as e: - logger.warning(f"Error processing response chunk: {str(e)}") - # Continue reading more chunks as this might not be the complete response - continue - except socket.timeout: - logger.warning("Socket timeout during receive") - raise Exception("Timeout receiving Unity response") - except Exception as e: - logger.error(f"Error during receive: {str(e)}") - raise + json.loads(data.decode('utf-8')) + return data + except Exception: + continue + + def _read_exact(self, sock: socket.socket, n: int) -> bytes: + buf = bytearray(n) + view = memoryview(buf) + read = 0 + while read < n: + r = sock.recv_into(view[read:]) + if r == 0: + raise Exception("Connection closed during read") + read += r + return bytes(buf) def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" @@ -160,13 +153,14 @@ def read_status_file() -> dict | None: # Build payload if command_type == 'ping': - payload = b'ping' + body = b'ping' else: command = {"type": command_type, "params": params or {}} - payload = json.dumps(command, ensure_ascii=False).encode('utf-8') + body = json.dumps(command, ensure_ascii=False).encode('utf-8') - # Send - self.sock.sendall(payload) + # Send with 8-byte big-endian length prefix for robustness + header = struct.pack('>Q', len(body)) + self.sock.sendall(header + body) # During retry bursts use a short receive timeout if attempt > 0 and last_short_timeout is None: diff --git a/UnityMcpBridge/UnityMcpServer~/src/uv.lock b/UnityMcpBridge/UnityMcpServer~/src/uv.lock index bc3e54ca..de0cd446 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/uv.lock +++ b/UnityMcpBridge/UnityMcpServer~/src/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 1 -requires-python = ">=3.12" +requires-python = ">=3.10" [[package]] name = "annotated-types" @@ -16,6 +16,7 @@ name = "anyio" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -55,6 +56,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -179,6 +192,33 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, @@ -207,6 +247,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, ] [[package]] @@ -247,6 +296,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } wheels = [ @@ -342,6 +392,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } wheels = [ diff --git a/claude-chunk.md b/claude-chunk.md new file mode 100644 index 00000000..5857bf1f --- /dev/null +++ b/claude-chunk.md @@ -0,0 +1,51 @@ +### macOS: Claude CLI fails to start (dyld ICU library not loaded) + +- Symptoms + - Unity MCP error: “Failed to start Claude CLI. dyld: Library not loaded: /usr/local/opt/icu4c/lib/libicui18n.71.dylib …” + - Running `claude` in Terminal fails with missing `libicui18n.xx.dylib`. + +- Cause + - Homebrew Node (or the `claude` binary) was linked against an ICU version that’s no longer installed; dyld can’t find that dylib. + +- Fix options (pick one) + - Reinstall Homebrew Node (relinks to current ICU), then reinstall CLI: + ```bash + brew update + brew reinstall node + npm uninstall -g @anthropic-ai/claude-code + npm install -g @anthropic-ai/claude-code + ``` + - Use NVM Node (avoids Homebrew ICU churn): + ```bash + nvm install --lts + nvm use --lts + npm install -g @anthropic-ai/claude-code + # Unity MCP → Claude Code → Choose Claude Location → ~/.nvm/versions/node//bin/claude + ``` + - Use the native installer (puts claude in a stable path): + ```bash + # macOS/Linux + curl -fsSL https://claude.ai/install.sh | bash + # Unity MCP → Claude Code → Choose Claude Location → /opt/homebrew/bin/claude or ~/.local/bin/claude + ``` + +- After fixing + - In Unity MCP (Claude Code), click “Choose Claude Location” and select the working `claude` binary, then Register again. + +- More details + - See: Troubleshooting Unity MCP and Claude Code + +--- + +### FAQ (Claude Code) + +- Q: Unity can’t find `claude` even though Terminal can. + - A: macOS apps launched from Finder/Hub don’t inherit your shell PATH. In the Unity MCP window, click “Choose Claude Location” and select the absolute path (e.g., `/opt/homebrew/bin/claude` or `~/.nvm/versions/node//bin/claude`). + +- Q: I installed via NVM; where is `claude`? + - A: Typically `~/.nvm/versions/node//bin/claude`. Our UI also scans NVM versions and you can browse to it via “Choose Claude Location”. + +- Q: The Register button says “Claude Not Found”. + - A: Install the CLI or set the path. Click the orange “[HELP]” link in the Unity MCP window for step‑by‑step install instructions, then choose the binary location. + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..10fbb385 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "unity-mcp", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{} From 616d3998b58af1d71b6d067d2f575bea666964eb Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 13 Aug 2025 19:02:29 -0700 Subject: [PATCH 61/69] chore: bump package version to 2.1.1 --- UnityMcpBridge/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index 445f448b..a5d06510 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "2.1.0", + "version": "2.1.1", "displayName": "Unity MCP Bridge", "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", "unity": "2020.3", From fae347b03a2a4b3f90ff25c23aa148ba0f8b2985 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 13 Aug 2025 19:06:33 -0700 Subject: [PATCH 62/69] revert: remove protocol framing changes from config-stability PR (keep config-only changes) --- UnityMcpBridge/Editor/UnityMcpBridge.cs | 137 +----------------- .../UnityMcpServer~/src/unity_connection.py | 104 ++++++------- 2 files changed, 63 insertions(+), 178 deletions(-) diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index fc06dd28..b7e8ef0e 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -395,80 +395,22 @@ private static async Task HandleClientAsync(TcpClient client) using (client) using (NetworkStream stream = client.GetStream()) { - const int MaxMessageBytes = 64 * 1024 * 1024; // 64 MB safety cap byte[] buffer = new byte[8192]; while (isRunning) { try { - // Read message with optional length prefix (8-byte big-endian) - bool usedFraming = false; - string commandText = null; - - // First, attempt to read an 8-byte header - byte[] header = new byte[8]; - int headerFilled = 0; - while (headerFilled < 8) + int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); + if (bytesRead == 0) { - int r = await stream.ReadAsync(header, headerFilled, 8 - headerFilled); - if (r == 0) - { - // Disconnected - return; - } - headerFilled += r; - } - - // Interpret header as big-endian payload length, with plausibility check - ulong payloadLen = ReadUInt64BigEndian(header); - if (payloadLen > 0 && payloadLen <= (ulong)MaxMessageBytes) - { - // Framed message path - usedFraming = true; - byte[] payload = await ReadExactAsync(stream, (int)payloadLen); - commandText = System.Text.Encoding.UTF8.GetString(payload); - } - else - { - // Legacy path: treat header bytes as the beginning of a JSON/plain message and read until we have a full JSON - usedFraming = false; - using var ms = new MemoryStream(); - ms.Write(header, 0, header.Length); - - // Read available data in chunks; stop when we have valid JSON or ping, or when no more data available for now - while (true) - { - // If we already have enough text, try to interpret - string currentText = System.Text.Encoding.UTF8.GetString(ms.ToArray()); - string trimmed = currentText.Trim(); - if (trimmed == "ping") - { - commandText = trimmed; - break; - } - if (IsValidJson(trimmed)) - { - commandText = trimmed; - break; - } - - // Read next chunk - int r = await stream.ReadAsync(buffer, 0, buffer.Length); - if (r == 0) - { - // Disconnected mid-message; fall back to whatever we have - commandText = currentText; - break; - } - ms.Write(buffer, 0, r); - - if (ms.Length > MaxMessageBytes) - { - throw new IOException($"Incoming message exceeded {MaxMessageBytes} bytes cap"); - } - } + break; // Client disconnected } + string commandText = System.Text.Encoding.UTF8.GetString( + buffer, + 0, + bytesRead + ); string commandId = Guid.NewGuid().ToString(); TaskCompletionSource tcs = new(); @@ -480,14 +422,6 @@ private static async Task HandleClientAsync(TcpClient client) /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); - - if (usedFraming) - { - // Mirror framing for response - byte[] outHeader = new byte[8]; - WriteUInt64BigEndian(outHeader, (ulong)pingResponseBytes.Length); - await stream.WriteAsync(outHeader, 0, outHeader.Length); - } await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length); continue; } @@ -499,12 +433,6 @@ private static async Task HandleClientAsync(TcpClient client) string response = await tcs.Task; byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); - if (usedFraming) - { - byte[] outHeader = new byte[8]; - WriteUInt64BigEndian(outHeader, (ulong)responseBytes.Length); - await stream.WriteAsync(outHeader, 0, outHeader.Length); - } await stream.WriteAsync(responseBytes, 0, responseBytes.Length); } catch (Exception ex) @@ -516,55 +444,6 @@ private static async Task HandleClientAsync(TcpClient client) } } - // Read exactly count bytes or throw if stream closes prematurely - private static async Task ReadExactAsync(NetworkStream stream, int count) - { - byte[] data = new byte[count]; - int offset = 0; - while (offset < count) - { - int r = await stream.ReadAsync(data, offset, count - offset); - if (r == 0) - { - throw new IOException("Connection closed before reading expected bytes"); - } - offset += r; - } - return data; - } - - private static ulong ReadUInt64BigEndian(byte[] buffer) - { - if (buffer == null || buffer.Length < 8) - { - return 0UL; - } - return ((ulong)buffer[0] << 56) - | ((ulong)buffer[1] << 48) - | ((ulong)buffer[2] << 40) - | ((ulong)buffer[3] << 32) - | ((ulong)buffer[4] << 24) - | ((ulong)buffer[5] << 16) - | ((ulong)buffer[6] << 8) - | buffer[7]; - } - - private static void WriteUInt64BigEndian(byte[] dest, ulong value) - { - if (dest == null || dest.Length < 8) - { - throw new ArgumentException("Destination buffer too small for UInt64"); - } - dest[0] = (byte)(value >> 56); - dest[1] = (byte)(value >> 48); - dest[2] = (byte)(value >> 40); - dest[3] = (byte)(value >> 32); - dest[4] = (byte)(value >> 24); - dest[5] = (byte)(value >> 16); - dest[6] = (byte)(value >> 8); - dest[7] = (byte)(value); - } - private static void ProcessCommands() { List processedIds = new(); diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index bf030d09..9bad736d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -9,7 +9,6 @@ from typing import Dict, Any from config import config from port_discovery import PortDiscovery -import struct # Configure logging using settings from config logging.basicConfig( @@ -54,52 +53,60 @@ def disconnect(self): finally: self.sock = None - def receive_full_response(self, sock) -> bytes: - """Receive a complete response from Unity using 8-byte length-prefixed framing, with legacy fallback.""" - sock.settimeout(config.connection_timeout) - # Try framed first + def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: + """Receive a complete response from Unity, handling chunked data.""" + chunks = [] + sock.settimeout(config.connection_timeout) # Use timeout from config try: - header = self._read_exact(sock, 8) - (payload_len,) = struct.unpack('>Q', header) - if 0 < payload_len <= (64 * 1024 * 1024): - return self._read_exact(sock, payload_len) - # Implausible length -> treat as legacy stream; fall through - legacy_prefix = header - except Exception: - # Could not read header — treat as legacy - legacy_prefix = b'' - - # Legacy: read until parses as JSON or times out - chunks: list[bytes] = [] - if legacy_prefix: - chunks.append(legacy_prefix) - while True: - chunk = sock.recv(config.buffer_size) - if not chunk: + while True: + chunk = sock.recv(buffer_size) + if not chunk: + if not chunks: + raise Exception("Connection closed before receiving data") + break + chunks.append(chunk) + + # Process the data received so far data = b''.join(chunks) - if not data: - raise Exception("Connection closed before receiving data") - return data - chunks.append(chunk) - data = b''.join(chunks) - try: - if data.strip() == b'ping': + decoded_data = data.decode('utf-8') + + # Check if we've received a complete response + try: + # Special case for ping-pong + if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): + logger.debug("Received ping response") + return data + + # Handle escaped quotes in the content + if '"content":' in decoded_data: + # Find the content field and its value + content_start = decoded_data.find('"content":') + 9 + content_end = decoded_data.rfind('"', content_start) + if content_end > content_start: + # Replace escaped quotes in content with regular quotes + content = decoded_data[content_start:content_end] + content = content.replace('\\"', '"') + decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:] + + # Validate JSON format + json.loads(decoded_data) + + # If we get here, we have valid JSON + logger.info(f"Received complete response ({len(data)} bytes)") return data - json.loads(data.decode('utf-8')) - return data - except Exception: - continue - - def _read_exact(self, sock: socket.socket, n: int) -> bytes: - buf = bytearray(n) - view = memoryview(buf) - read = 0 - while read < n: - r = sock.recv_into(view[read:]) - if r == 0: - raise Exception("Connection closed during read") - read += r - return bytes(buf) + except json.JSONDecodeError: + # We haven't received a complete valid JSON response yet + continue + except Exception as e: + logger.warning(f"Error processing response chunk: {str(e)}") + # Continue reading more chunks as this might not be the complete response + continue + except socket.timeout: + logger.warning("Socket timeout during receive") + raise Exception("Timeout receiving Unity response") + except Exception as e: + logger.error(f"Error during receive: {str(e)}") + raise def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" @@ -153,14 +160,13 @@ def read_status_file() -> dict | None: # Build payload if command_type == 'ping': - body = b'ping' + payload = b'ping' else: command = {"type": command_type, "params": params or {}} - body = json.dumps(command, ensure_ascii=False).encode('utf-8') + payload = json.dumps(command, ensure_ascii=False).encode('utf-8') - # Send with 8-byte big-endian length prefix for robustness - header = struct.pack('>Q', len(body)) - self.sock.sendall(header + body) + # Send + self.sock.sendall(payload) # During retry bursts use a short receive timeout if attempt > 0 and last_short_timeout is None: From 80d311ec13373723ae5f8ba45c9fa69b1c8d3ff2 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Wed, 13 Aug 2025 19:15:03 -0700 Subject: [PATCH 63/69] chore(windows): WinGet Links resolution uses ProgramFiles (machine-wide) after LOCALAPPDATA; drop ProgramData; update comment --- UnityMcpBridge/Editor/Helpers/ServerInstaller.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index ddb4a506..aa02e7ab 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -278,7 +278,6 @@ internal static string FindUvPath() string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty; string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; - string programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) ?? string.Empty; // optional fallback // Fast path: resolve from PATH first try @@ -309,10 +308,8 @@ internal static string FindUvPath() candidates = new[] { // Preferred: WinGet Links shims (stable entrypoints) - // Per-user shim, then machine-wide shim + // Per-user shim (LOCALAPPDATA) → machine-wide shim (Program Files\WinGet\Links) Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"), - Path.Combine(programData, "Microsoft", "WinGet", "Links", "uv.exe"), - // ProgramFiles Links is uncommon; keep as low-priority fallback Path.Combine(programFiles, "WinGet", "Links", "uv.exe"), // Common per-user installs From 108dd808836ac0531225358627cc8ef1e472eaf8 Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 14 Aug 2025 07:29:17 -0700 Subject: [PATCH 64/69] Update README.md slight cleanup --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 17d63c86..e132d9b2 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,6 @@ Unity MCP connects your tools using two components: ### Prerequisites - * **Git CLI:** For cloning the server code. [Download Git](https://git-scm.com/downloads) * **Python:** Version 3.12 or newer. [Download Python](https://www.python.org/downloads/) * **Unity Hub & Editor:** Version 2020.3 LTS or newer. [Download Unity](https://unity.com/download) * **uv (Python package manager):** @@ -100,7 +99,7 @@ Unity MCP connects your tools using two components: **Note:** Without Roslyn, script validation falls back to basic structural checks. Roslyn enables full C# compiler diagnostics with precise error reporting. -### Step 1: Install the Unity Package (Bridge) +### 🌟Step 1: Install the Unity Package (Bridge)🌟 1. Open your Unity project. 2. Go to `Window > Package Manager`. @@ -126,11 +125,11 @@ Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installe 2. Click `Auto-Setup`. 3. Look for a green status indicator 🟢 and "Connected ✓". *(This attempts to modify the MCP Client\'s config file automatically).* -Client-specific notes +
Client-specific troubleshooting -- **VSCode**: uses `Code/User/mcp.json` with top-level `servers.unityMCP` and `"type": "stdio"`. On Windows, Unity MCP writes an absolute `uv.exe` (prefers WinGet Links shim) to avoid PATH issues. -- **Cursor / Windsurf**: if `uv` is missing, the Unity MCP window shows "uv Not Found" with a quick [HELP] link and a "Choose UV Install Location" button. -- **Claude Code**: if `claude` isn't found, the window shows "Claude Not Found" with [HELP] and a "Choose Claude Location" button. Unregister now updates the UI immediately. + - **VSCode**: uses `Code/User/mcp.json` with top-level `servers.unityMCP` and `"type": "stdio"`. On Windows, Unity MCP writes an absolute `uv.exe` (prefers WinGet Links shim) to avoid PATH issues. + - **Cursor / Windsurf** [(**help link**)](https://github.com/CoplayDev/unity-mcp/wiki/1.-Fix-Unity-MCP-and-Cursor,-VSCode-&-Windsurf): if `uv` is missing, the Unity MCP window shows "uv Not Found" with a quick [HELP] link and a "Choose UV Install Location" button. + - **Claude Code** [(**help link**)](https://github.com/CoplayDev/unity-mcp/wiki/2.-Fix-Unity-MCP-and-Claude-Code): if `claude` isn't found, the window shows "Claude Not Found" with [HELP] and a "Choose Claude Location" button. Unregister now updates the UI immediately.
**Option B: Manual Configuration** From 401e27654ae06f5cdcfee720821f9192b2c8558c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Thu, 14 Aug 2025 22:37:34 -0700 Subject: [PATCH 65/69] Unity MCP: stable framing handshake + non-blocking script writes; remove blob stream tools; simplify tool registration - Python server: always consume handshake and negotiate framing on reconnects (prevents invalid framed length).\n- C#: strict FRAMING=1 handshake and NoDelay; debounce AssetDatabase/compilation.\n- Tools: keep manage_script + script edits; remove manage_script_stream and test tools from default registration.\n- Editor window: guard against auto retargeting IDE config. --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 85 +++++++++++-- UnityMcpBridge/Editor/UnityMcpBridge.cs | 119 ++++++++++++++++-- .../Editor/Windows/UnityMcpEditorWindow.cs | 36 +++--- UnityMcpBridge/UnityMcpServer~/src/server.py | 16 +++ .../UnityMcpServer~/src/unity_connection.py | 94 +++++++++----- 5 files changed, 285 insertions(+), 65 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index d79e17a6..8fa018b1 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -6,6 +6,7 @@ using UnityEditor; using UnityEngine; using UnityMcpBridge.Editor.Helpers; +using System.Threading; #if USE_ROSLYN using Microsoft.CodeAnalysis; @@ -217,13 +218,20 @@ string namespaceName try { - File.WriteAllText(fullPath, contents); - AssetDatabase.ImportAsset(relativePath); - AssetDatabase.Refresh(); // Ensure Unity recognizes the new script - return Response.Success( + // Atomic-ish create + var enc = System.Text.Encoding.UTF8; + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, contents, enc); + File.Move(tmp, fullPath); + + var ok = Response.Success( $"Script '{name}.cs' created successfully at '{relativePath}'.", - new { path = relativePath } + new { path = relativePath, scheduledRefresh = true } ); + + // Schedule heavy work AFTER replying + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + return ok; } catch (Exception e) { @@ -298,13 +306,33 @@ string contents try { - File.WriteAllText(fullPath, contents); - AssetDatabase.ImportAsset(relativePath); // Re-import to reflect changes - AssetDatabase.Refresh(); - return Response.Success( + // Safe write with atomic replace when available + var encoding = System.Text.Encoding.UTF8; + string tempPath = fullPath + ".tmp"; + File.WriteAllText(tempPath, contents, encoding); + + string backupPath = fullPath + ".bak"; + try + { + File.Replace(tempPath, fullPath, backupPath); + } + catch (PlatformNotSupportedException) + { + // Fallback for platforms without File.Replace + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + } + + // Prepare success response BEFORE any operation that can trigger a domain reload + var ok = Response.Success( $"Script '{name}.cs' updated successfully at '{relativePath}'.", - new { path = relativePath } + new { path = relativePath, scheduledRefresh = true } ); + + // Schedule a debounced import/compile on next editor tick to avoid stalling the reply + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + + return ok; } catch (Exception e) { @@ -1028,3 +1056,40 @@ private static void ValidateSemanticRules(string contents, System.Collections.Ge } } +// Debounced refresh/compile scheduler to coalesce bursts of edits +static class RefreshDebounce +{ + private static int _pending; + private static DateTime _last; + + public static void Schedule(string relPath, TimeSpan window) + { + Interlocked.Exchange(ref _pending, 1); + var now = DateTime.UtcNow; + if ((now - _last) < window) return; + _last = now; + + EditorApplication.delayCall += () => + { + if (Interlocked.Exchange(ref _pending, 0) == 1) + { + // Prefer targeted import and script compile over full refresh + AssetDatabase.ImportAsset(relPath, ImportAssetOptions.ForceUpdate); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + // Fallback if needed: + // AssetDatabase.Refresh(); + } + }; + } +} + +static class ManageScriptRefreshHelpers +{ + public static void ScheduleScriptRefresh(string relPath) + { + RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200)); + } +} + diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index b7e8ef0e..38030e28 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -395,22 +395,68 @@ private static async Task HandleClientAsync(TcpClient client) using (client) using (NetworkStream stream = client.GetStream()) { + const int MaxMessageBytes = 64 * 1024 * 1024; // 64 MB safety cap + bool framingEnabledForConnection = false; + try + { + var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; + Debug.Log($"UNITY-MCP: Client connected {ep}"); + } + catch { } + // Strict framing: always require FRAMING=1 and frame all I/O + try + { + client.NoDelay = true; + } + catch { } + try + { + string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n"; + byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake); + await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length); + } + catch { /* ignore */ } + framingEnabledForConnection = true; + Debug.Log("UNITY-MCP: Sent handshake FRAMING=1 (strict)"); + byte[] buffer = new byte[8192]; while (isRunning) { try { - int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); - if (bytesRead == 0) + // Strict framed mode + string commandText = null; + bool usedFraming = true; + + if (true) { - break; // Client disconnected + // Enforced framed mode for this connection + byte[] header = new byte[8]; + int headerFilled = 0; + while (headerFilled < 8) + { + int r = await stream.ReadAsync(header, headerFilled, 8 - headerFilled); + if (r == 0) + { + return; // disconnected + } + headerFilled += r; + } + ulong payloadLen = ReadUInt64BigEndian(header); + if (payloadLen == 0UL || payloadLen > (ulong)MaxMessageBytes) + { + throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); + } + byte[] payload = await ReadExactAsync(stream, (int)payloadLen); + commandText = System.Text.Encoding.UTF8.GetString(payload); } - string commandText = System.Text.Encoding.UTF8.GetString( - buffer, - 0, - bytesRead - ); + try + { + var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; + Debug.Log($"UNITY-MCP: recv {(usedFraming ? "framed" : "legacy")}: {preview}"); + } + catch { } string commandId = Guid.NewGuid().ToString(); TaskCompletionSource tcs = new(); @@ -422,6 +468,12 @@ private static async Task HandleClientAsync(TcpClient client) /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); + if (framingEnabledForConnection) + { + byte[] outHeader = new byte[8]; + WriteUInt64BigEndian(outHeader, (ulong)pingResponseBytes.Length); + await stream.WriteAsync(outHeader, 0, outHeader.Length); + } await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length); continue; } @@ -433,6 +485,12 @@ private static async Task HandleClientAsync(TcpClient client) string response = await tcs.Task; byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); + if (true) + { + byte[] outHeader = new byte[8]; + WriteUInt64BigEndian(outHeader, (ulong)responseBytes.Length); + await stream.WriteAsync(outHeader, 0, outHeader.Length); + } await stream.WriteAsync(responseBytes, 0, responseBytes.Length); } catch (Exception ex) @@ -444,6 +502,51 @@ private static async Task HandleClientAsync(TcpClient client) } } + private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count) + { + byte[] data = new byte[count]; + int offset = 0; + while (offset < count) + { + int r = await stream.ReadAsync(data, offset, count - offset); + if (r == 0) + { + throw new System.IO.IOException("Connection closed before reading expected bytes"); + } + offset += r; + } + return data; + } + + private static ulong ReadUInt64BigEndian(byte[] buffer) + { + if (buffer == null || buffer.Length < 8) return 0UL; + return ((ulong)buffer[0] << 56) + | ((ulong)buffer[1] << 48) + | ((ulong)buffer[2] << 40) + | ((ulong)buffer[3] << 32) + | ((ulong)buffer[4] << 24) + | ((ulong)buffer[5] << 16) + | ((ulong)buffer[6] << 8) + | buffer[7]; + } + + private static void WriteUInt64BigEndian(byte[] dest, ulong value) + { + if (dest == null || dest.Length < 8) + { + throw new System.ArgumentException("Destination buffer too small for UInt64"); + } + dest[0] = (byte)(value >> 56); + dest[1] = (byte)(value >> 48); + dest[2] = (byte)(value >> 40); + dest[3] = (byte)(value >> 32); + dest[4] = (byte)(value >> 24); + dest[5] = (byte)(value >> 16); + dest[6] = (byte)(value >> 8); + dest[7] = (byte)(value); + } + private static void ProcessCommands() { List processedIds = new(); diff --git a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs index 9e42d7ff..19446406 100644 --- a/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/UnityMcpEditorWindow.cs @@ -1550,29 +1550,33 @@ private void CheckMcpConfiguration(McpClient mcpClient) } else { - // Attempt auto-rewrite once if the package path changed - try + // Attempt auto-rewrite once if the package path changed, but only when explicitly enabled + bool autoManage = UnityEditor.EditorPrefs.GetBool("UnityMCP.AutoManageIDEConfig", false); + if (autoManage) { - string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient); - if (rewriteResult == "Configured successfully") + try { - if (debugLogsEnabled) + string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient); + if (rewriteResult == "Configured successfully") { - UnityEngine.Debug.Log($"UnityMCP: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}"); + if (debugLogsEnabled) + { + UnityEngine.Debug.Log($"UnityMCP: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}"); + } + mcpClient.SetStatus(McpStatus.Configured); + } + else + { + mcpClient.SetStatus(McpStatus.IncorrectPath); } - mcpClient.SetStatus(McpStatus.Configured); } - else + catch (Exception ex) { mcpClient.SetStatus(McpStatus.IncorrectPath); - } - } - catch (Exception ex) - { - mcpClient.SetStatus(McpStatus.IncorrectPath); - if (debugLogsEnabled) - { - UnityEngine.Debug.LogWarning($"UnityMCP: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); + if (debugLogsEnabled) + { + UnityEngine.Debug.LogWarning($"UnityMCP: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); + } } } } diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 55360b57..52633ef4 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -1,11 +1,13 @@ from mcp.server.fastmcp import FastMCP, Context, Image import logging +from logging.handlers import RotatingFileHandler from dataclasses import dataclass from contextlib import asynccontextmanager from typing import AsyncIterator, Dict, Any, List from config import config from tools import register_all_tools from unity_connection import get_unity_connection, UnityConnection +from pathlib import Path # Configure logging using settings from config logging.basicConfig( @@ -14,6 +16,20 @@ ) logger = logging.getLogger("unity-mcp-server") +# File logging to avoid stdout interference with MCP stdio +try: + log_dir = Path.home() / ".unity-mcp" + log_dir.mkdir(parents=True, exist_ok=True) + file_handler = RotatingFileHandler(str(log_dir / "server.log"), maxBytes=5*1024*1024, backupCount=3) + file_handler.setFormatter(logging.Formatter(config.log_format)) + file_handler.setLevel(getattr(logging, config.log_level)) + logger.addHandler(file_handler) + # Prevent duplicate propagation to root handlers + logger.propagate = False +except Exception: + # If file logging setup fails, continue with stderr logging only + pass + # Global connection state _unity_connection: UnityConnection = None diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index 9bad736d..bc602040 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -1,6 +1,7 @@ import socket import json import logging +import struct from dataclasses import dataclass from pathlib import Path import time @@ -23,6 +24,7 @@ class UnityConnection: host: str = config.unity_host port: int = None # Will be set dynamically sock: socket.socket = None # Socket for Unity communication + use_framing: bool = False # Negotiated per-connection def __post_init__(self): """Set port from discovery if not explicitly provided""" @@ -37,6 +39,19 @@ def connect(self) -> bool: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((self.host, self.port)) logger.info(f"Connected to Unity at {self.host}:{self.port}") + + # Strict handshake: require FRAMING=1 + try: + self.sock.settimeout(1.0) + greeting = self.sock.recv(256) + text = greeting.decode('ascii', errors='ignore') if greeting else '' + if 'FRAMING=1' in text: + self.use_framing = True + logger.info('Unity MCP handshake received: FRAMING=1 (strict)') + else: + raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') + finally: + self.sock.settimeout(config.connection_timeout) return True except Exception as e: logger.error(f"Failed to connect to Unity: {str(e)}") @@ -53,8 +68,33 @@ def disconnect(self): finally: self.sock = None + def _read_exact(self, sock: socket.socket, count: int) -> bytes: + data = bytearray() + while len(data) < count: + chunk = sock.recv(count - len(data)) + if not chunk: + raise Exception("Connection closed before reading expected bytes") + data.extend(chunk) + return bytes(data) + def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: """Receive a complete response from Unity, handling chunked data.""" + if self.use_framing: + try: + header = self._read_exact(sock, 8) + payload_len = struct.unpack('>Q', header)[0] + if payload_len == 0 or payload_len > (64 * 1024 * 1024): + raise Exception(f"Invalid framed length: {payload_len}") + payload = self._read_exact(sock, payload_len) + logger.info(f"Received framed response ({len(payload)} bytes)") + return payload + except socket.timeout: + logger.warning("Socket timeout during framed receive") + raise Exception("Timeout receiving Unity response") + except Exception as e: + logger.error(f"Error during framed receive: {str(e)}") + raise + chunks = [] sock.settimeout(config.connection_timeout) # Use timeout from config try: @@ -166,13 +206,26 @@ def read_status_file() -> dict | None: payload = json.dumps(command, ensure_ascii=False).encode('utf-8') # Send - self.sock.sendall(payload) + try: + logger.debug(f"send {len(payload)} bytes; mode={'framed' if self.use_framing else 'legacy'}; head={(payload[:32]).decode('utf-8','ignore')}") + except Exception: + pass + if self.use_framing: + header = struct.pack('>Q', len(payload)) + self.sock.sendall(header) + self.sock.sendall(payload) + else: + self.sock.sendall(payload) # During retry bursts use a short receive timeout if attempt > 0 and last_short_timeout is None: last_short_timeout = self.sock.gettimeout() self.sock.settimeout(1.0) response_data = self.receive_full_response(self.sock) + try: + logger.debug(f"recv {len(response_data)} bytes; mode={'framed' if self.use_framing else 'legacy'}; head={(response_data[:32]).decode('utf-8','ignore')}") + except Exception: + pass # restore steady-state timeout if changed if last_short_timeout is not None: self.sock.settimeout(config.connection_timeout) @@ -241,43 +294,22 @@ def read_status_file() -> dict | None: _unity_connection = None def get_unity_connection() -> UnityConnection: - """Retrieve or establish a persistent Unity connection.""" + """Retrieve or establish a persistent Unity connection. + + Note: Do NOT ping on every retrieval to avoid connection storms. Rely on + send_command() exceptions to detect broken sockets and reconnect there. + """ global _unity_connection if _unity_connection is not None: - try: - # Try to ping with a short timeout to verify connection - result = _unity_connection.send_command("ping") - # If we get here, the connection is still valid - logger.debug("Reusing existing Unity connection") - return _unity_connection - except Exception as e: - logger.warning(f"Existing connection failed: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - - # Create a new connection + return _unity_connection + logger.info("Creating new Unity connection") _unity_connection = UnityConnection() if not _unity_connection.connect(): _unity_connection = None raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") - - try: - # Verify the new connection works - _unity_connection.send_command("ping") - logger.info("Successfully established new Unity connection") - return _unity_connection - except Exception as e: - logger.error(f"Could not verify new connection: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") + logger.info("Connected to Unity on startup") + return _unity_connection # ----------------------------- From 7eeac659f50212bbc7bb4fbd22805d8d61e3555f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 15 Aug 2025 10:59:35 -0700 Subject: [PATCH 66/69] Bridge framing hardening: 64MiB cap, zero-length reject, timeout ReadExact, safe write framing; remove unused vars --- UnityMcpBridge/Editor/UnityMcpBridge.cs | 56 +++++++++++++++++-------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/UnityMcpBridge/Editor/UnityMcpBridge.cs b/UnityMcpBridge/Editor/UnityMcpBridge.cs index 38030e28..fa707483 100644 --- a/UnityMcpBridge/Editor/UnityMcpBridge.cs +++ b/UnityMcpBridge/Editor/UnityMcpBridge.cs @@ -35,6 +35,8 @@ private static Dictionary< > commandQueue = new(); private static int currentUnityPort = 6400; // Dynamic port, starts with default private static bool isAutoConnectMode = false; + private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads + private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients // Debug helpers private static bool IsDebugEnabled() @@ -395,8 +397,7 @@ private static async Task HandleClientAsync(TcpClient client) using (client) using (NetworkStream stream = client.GetStream()) { - const int MaxMessageBytes = 64 * 1024 * 1024; // 64 MB safety cap - bool framingEnabledForConnection = false; + // Framed I/O only; legacy mode removed try { var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; @@ -416,7 +417,6 @@ private static async Task HandleClientAsync(TcpClient client) await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length); } catch { /* ignore */ } - framingEnabledForConnection = true; Debug.Log("UNITY-MCP: Sent handshake FRAMING=1 (strict)"); byte[] buffer = new byte[8192]; @@ -431,23 +431,14 @@ private static async Task HandleClientAsync(TcpClient client) if (true) { // Enforced framed mode for this connection - byte[] header = new byte[8]; - int headerFilled = 0; - while (headerFilled < 8) - { - int r = await stream.ReadAsync(header, headerFilled, 8 - headerFilled); - if (r == 0) - { - return; // disconnected - } - headerFilled += r; - } + byte[] header = await ReadExactAsync(stream, 8, FrameIOTimeoutMs); ulong payloadLen = ReadUInt64BigEndian(header); - if (payloadLen == 0UL || payloadLen > (ulong)MaxMessageBytes) + if (payloadLen == 0UL || payloadLen > MaxFrameBytes) { throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); } - byte[] payload = await ReadExactAsync(stream, (int)payloadLen); + int payloadLenInt = checked((int)payloadLen); + byte[] payload = await ReadExactAsync(stream, payloadLenInt, FrameIOTimeoutMs); commandText = System.Text.Encoding.UTF8.GetString(payload); } @@ -468,7 +459,10 @@ private static async Task HandleClientAsync(TcpClient client) /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); - if (framingEnabledForConnection) + if ((ulong)pingResponseBytes.Length > MaxFrameBytes) + { + throw new System.IO.IOException($"Frame too large: {pingResponseBytes.Length}"); + } { byte[] outHeader = new byte[8]; WriteUInt64BigEndian(outHeader, (ulong)pingResponseBytes.Length); @@ -485,7 +479,10 @@ private static async Task HandleClientAsync(TcpClient client) string response = await tcs.Task; byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); - if (true) + if ((ulong)responseBytes.Length > MaxFrameBytes) + { + throw new System.IO.IOException($"Frame too large: {responseBytes.Length}"); + } { byte[] outHeader = new byte[8]; WriteUInt64BigEndian(outHeader, (ulong)responseBytes.Length); @@ -518,6 +515,29 @@ private static async System.Threading.Tasks.Task ReadExactAsync(NetworkS return data; } + // Timeout-aware exact read helper; avoids indefinite stalls + private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs) + { + byte[] data = new byte[count]; + int offset = 0; + while (offset < count) + { + var readTask = stream.ReadAsync(data, offset, count - offset); + var completed = await System.Threading.Tasks.Task.WhenAny(readTask, System.Threading.Tasks.Task.Delay(timeoutMs)); + if (completed != readTask) + { + throw new System.IO.IOException("Read timed out"); + } + int r = readTask.Result; + if (r == 0) + { + throw new System.IO.IOException("Connection closed before reading expected bytes"); + } + offset += r; + } + return data; + } + private static ulong ReadUInt64BigEndian(byte[] buffer) { if (buffer == null || buffer.Length < 8) return 0UL; From eafe3095c7284bed1a953883fd74223b93ee3b63 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 15 Aug 2025 14:15:31 -0700 Subject: [PATCH 67/69] ManageScript: improve method span parsing and validation behavior for MCP edit ops; mitigate false 'no opening brace' errors and allow relaxed validation for text edits --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 652 +++++++++++++++++++- 1 file changed, 632 insertions(+), 20 deletions(-) diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 8fa018b1..7c9861a5 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Collections.Generic; using System.Text.RegularExpressions; using Newtonsoft.Json.Linq; using UnityEditor; @@ -48,6 +49,47 @@ namespace UnityMcpBridge.Editor.Tools /// public static class ManageScript { + /// + /// Resolves a directory under Assets/, preventing traversal and escaping. + /// Returns fullPathDir on disk and canonical 'Assets/...' relative path. + /// + private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, out string relPathSafe) + { + string assets = Application.dataPath.Replace('\\', '/'); + string targetDir = Path.Combine(assets, (relDir ?? "Scripts")).Replace('\\', '/'); + string full = Path.GetFullPath(targetDir).Replace('\\', '/'); + + bool underAssets = full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase) + || string.Equals(full, assets, StringComparison.OrdinalIgnoreCase); + if (!underAssets) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + + // Best-effort symlink guard: if directory is a reparse point/symlink, reject + try + { + var di = new DirectoryInfo(full); + if (di.Exists) + { + var attrs = di.Attributes; + if ((attrs & FileAttributes.ReparsePoint) != 0) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + } + } + catch { /* best effort; proceed */ } + + fullPathDir = full; + string tail = full.Length > assets.Length ? full.Substring(assets.Length).TrimStart('/') : string.Empty; + relPathSafe = ("Assets/" + tail).TrimEnd('/'); + return true; + } /// /// Main handler for script management actions. /// @@ -97,29 +139,16 @@ public static object HandleCommand(JObject @params) ); } - // Ensure path is relative to Assets/, removing any leading "Assets/" - // Set default directory to "Scripts" if path is not provided - string relativeDir = path ?? "Scripts"; // Default to "Scripts" if path is null - if (!string.IsNullOrEmpty(relativeDir)) - { - relativeDir = relativeDir.Replace('\\', '/').Trim('/'); - if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); - } - } - // Handle empty string case explicitly after processing - if (string.IsNullOrEmpty(relativeDir)) + // Resolve and harden target directory under Assets/ + if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) { - relativeDir = "Scripts"; // Ensure default if path was provided as "" or only "/" or "Assets/" + return Response.Error($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); } - // Construct paths + // Construct file paths string scriptFileName = $"{name}.cs"; - string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Application.dataPath ends in "Assets" string fullPath = Path.Combine(fullPathDir, scriptFileName); - string relativePath = Path.Combine("Assets", relativeDir, scriptFileName) - .Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes + string relativePath = Path.Combine(relPathSafeDir, scriptFileName).Replace('\\', '/'); // Ensure the target directory exists for create/update if (action == "create" || action == "update") @@ -154,6 +183,12 @@ public static object HandleCommand(JObject @params) return UpdateScript(fullPath, relativePath, name, contents); case "delete": return DeleteScript(fullPath, relativePath); + case "edit": + { + var edits = @params["edits"] as JArray; + var options = @params["options"] as JObject; + return EditScript(fullPath, relativePath, name, edits, options); + } default: return Response.Error( $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." @@ -222,7 +257,17 @@ string namespaceName var enc = System.Text.Encoding.UTF8; var tmp = fullPath + ".tmp"; File.WriteAllText(tmp, contents, enc); - File.Move(tmp, fullPath); + try + { + // Prefer atomic move within same volume + File.Move(tmp, fullPath); + } + catch (IOException) + { + // Cross-volume or other IO constraint: fallback to copy + File.Copy(tmp, fullPath, overwrite: true); + try { File.Delete(tmp); } catch { } + } var ok = Response.Success( $"Script '{name}.cs' created successfully at '{relativePath}'.", @@ -318,7 +363,12 @@ string contents } catch (PlatformNotSupportedException) { - // Fallback for platforms without File.Replace + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + } + catch (IOException) + { + // Cross-volume moves can throw IOException; fallback to copy File.Copy(tempPath, fullPath, true); try { File.Delete(tempPath); } catch { } } @@ -372,6 +422,568 @@ private static object DeleteScript(string fullPath, string relativePath) } } + /// + /// Structured edits (AST-backed where available) on existing scripts. + /// Supports class-level replace/delete with Roslyn span computation if USE_ROSLYN is defined, + /// otherwise falls back to a conservative balanced-brace scan. + /// + private static object EditScript( + string fullPath, + string relativePath, + string name, + JArray edits, + JObject options) + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + if (edits == null || edits.Count == 0) + return Response.Error("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + string working = original; + + try + { + var replacements = new List<(int start, int length, string text)>(); + + foreach (var e in edits) + { + var op = (JObject)e; + var mode = (op.Value("mode") ?? op.Value("op") ?? string.Empty).ToLowerInvariant(); + + switch (mode) + { + case "replace_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string replacement = ExtractReplacement(op); + + if (string.IsNullOrWhiteSpace(className)) + return Response.Error("replace_class requires 'className'."); + if (replacement == null) + return Response.Error("replace_class requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why)) + return Response.Error($"replace_class failed: {why}"); + + if (!ValidateClassSnippet(replacement, className, out var vErr)) + return Response.Error($"Replacement snippet invalid: {vErr}"); + + replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); + break; + } + + case "delete_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + if (string.IsNullOrWhiteSpace(className)) + return Response.Error("delete_class requires 'className'."); + + if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) + return Response.Error($"delete_class failed: {why}"); + + replacements.Add((s, l, string.Empty)); + break; + } + + case "replace_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string replacement = ExtractReplacement(op); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("replace_method requires 'methodName'."); + if (replacement == null) return Response.Error("replace_method requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"replace_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + return Response.Error($"replace_method failed: {whyMethod}"); + + replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); + break; + } + + case "delete_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("delete_method requires 'methodName'."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"delete_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + return Response.Error($"delete_method failed: {whyMethod}"); + + replacements.Add((mStart, mLen, string.Empty)); + break; + } + + case "insert_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string position = (op.Value("position") ?? "end").ToLowerInvariant(); + string afterMethodName = op.Value("afterMethodName"); + string afterReturnType = op.Value("afterReturnType"); + string afterParameters = op.Value("afterParametersSignature"); + string afterAttributesContains = op.Value("afterAttributesContains"); + string snippet = ExtractReplacement(op); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("insert_method requires 'className'."); + if (snippet == null) return Response.Error("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"insert_method failed to locate class: {whyClass}"); + + if (position == "after") + { + if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'."); + if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter)) + return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); + int insAt = aStart + aLen; + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + replacements.Add((insAt, 0, text)); + } + else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) + return Response.Error($"insert_method failed: {whyIns}"); + else + { + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + replacements.Add((insAt, 0, text)); + } + break; + } + + default: + return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method."); + } + } + + if (HasOverlaps(replacements)) + return Response.Error("Edits overlap; split into separate calls or adjust targets."); + + foreach (var r in replacements.OrderByDescending(r => r.start)) + working = working.Remove(r.start, r.length).Insert(r.start, r.text); + + // Validate result using override from options if provided; otherwise GUI strictness + var level = GetValidationLevelFromGUI(); + try + { + var validateOpt = options?["validate"]?.ToString()?.ToLowerInvariant(); + if (!string.IsNullOrEmpty(validateOpt)) + { + level = validateOpt switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "comprehensive" => ValidationLevel.Comprehensive, + "strict" => ValidationLevel.Strict, + _ => level + }; + } + } + catch { /* ignore option parsing issues */ } + if (!ValidateScriptSyntax(working, level, out var errors)) + return Response.Error("Script validation failed:\n" + string.Join("\n", errors ?? Array.Empty())); + else if (errors != null && errors.Length > 0) + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); + + // Atomic write with backup; schedule refresh + var enc = System.Text.Encoding.UTF8; + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + string backup = fullPath + ".bak"; + try { File.Replace(tmp, fullPath, backup); } + catch (PlatformNotSupportedException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } + catch (IOException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } + + // Decide refresh behavior + string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); + bool immediate = refreshMode == "immediate" || refreshMode == "sync"; + + var ok = Response.Success( + $"Applied {replacements.Count} structured edit(s) to '{relativePath}'.", + new { path = relativePath, editsApplied = replacements.Count, scheduledRefresh = !immediate } + ); + + if (immediate) + { + // Force an immediate import/compile on the main thread + AssetDatabase.ImportAsset(relativePath, ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + } + else + { + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + } + return ok; + } + catch (Exception ex) + { + return Response.Error($"Edit failed: {ex.Message}"); + } + } + + private static bool HasOverlaps(IEnumerable<(int start, int length, string text)> list) + { + var arr = list.OrderBy(x => x.start).ToArray(); + for (int i = 1; i < arr.Length; i++) + { + if (arr[i - 1].start + arr[i - 1].length > arr[i].start) + return true; + } + return false; + } + + private static string ExtractReplacement(JObject op) + { + var inline = op.Value("replacement"); + if (!string.IsNullOrEmpty(inline)) return inline; + + var b64 = op.Value("replacementBase64"); + if (!string.IsNullOrEmpty(b64)) + { + try { return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(b64)); } + catch { return null; } + } + return null; + } + + private static string NormalizeNewlines(string t) + { + if (string.IsNullOrEmpty(t)) return t; + return t.Replace("\r\n", "\n").Replace("\r", "\n"); + } + + private static bool ValidateClassSnippet(string snippet, string expectedName, out string err) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(snippet); + var root = tree.GetRoot(); + var classes = root.DescendantNodes().OfType().ToList(); + if (classes.Count != 1) { err = "snippet must contain exactly one class declaration"; return false; } + // Optional: enforce expected name + // if (classes[0].Identifier.ValueText != expectedName) { err = $"snippet declares '{classes[0].Identifier.ValueText}', expected '{expectedName}'"; return false; } + err = null; return true; + } + catch (Exception ex) { err = ex.Message; return false; } +#else + if (string.IsNullOrWhiteSpace(snippet) || !snippet.Contains("class ")) { err = "no 'class' keyword found in snippet"; return false; } + err = null; return true; +#endif + } + + private static bool TryComputeClassSpan(string source, string className, string ns, out int start, out int length, out string why) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(source); + var root = tree.GetRoot(); + var classes = root.DescendantNodes() + .OfType() + .Where(c => c.Identifier.ValueText == className); + + if (!string.IsNullOrEmpty(ns)) + { + classes = classes.Where(c => + (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns + || (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns); + } + + var list = classes.ToList(); + if (list.Count == 0) { start = length = 0; why = $"class '{className}' not found" + (ns != null ? $" in namespace '{ns}'" : ""); return false; } + if (list.Count > 1) { start = length = 0; why = $"class '{className}' matched {list.Count} declarations (partial/nested?). Disambiguate."; return false; } + + var cls = list[0]; + var span = cls.FullSpan; // includes attributes & leading trivia + start = span.Start; length = span.Length; why = null; return true; + } + catch + { + // fall back below + } +#endif + return TryComputeClassSpanBalanced(source, className, ns, out start, out length, out why); + } + + private static bool TryComputeClassSpanBalanced(string source, string className, string ns, out int start, out int length, out string why) + { + start = length = 0; why = null; + var idx = IndexOfClassToken(source, className); + if (idx < 0) { why = $"class '{className}' not found (balanced scan)"; return false; } + + if (!string.IsNullOrEmpty(ns) && !AppearsWithinNamespaceHeader(source, idx, ns)) + { why = $"class '{className}' not under namespace '{ns}' (balanced scan)"; return false; } + + // Include modifiers/attributes on the same line: back up to the start of line + int lineStart = idx; + while (lineStart > 0 && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + + int i = idx; + while (i < source.Length && source[i] != '{') i++; + if (i >= source.Length) { why = "no opening brace after class header"; return false; } + + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + int startSpan = lineStart; + for (; i < source.Length; i++) + { + char c = source[i]; + char n = i + 1 < source.Length ? source[i + 1] : '\0'; + + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') { depth++; } + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow"; return false; } + } + } + why = "unterminated class block"; return false; + } + + private static bool TryComputeMethodSpan( + string source, + int classStart, + int classLength, + string methodName, + string returnType, + string parametersSignature, + string attributesContains, + out int start, + out int length, + out string why) + { + start = length = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + // 1) Find the method header using a stricter regex (allows optional attributes above) + string rtPattern = string.IsNullOrEmpty(returnType) ? @"[^\s]+" : Regex.Escape(returnType).Replace("\\ ", "\\s+"); + string namePattern = Regex.Escape(methodName); + string paramsPattern = string.IsNullOrEmpty(parametersSignature) ? @"[\s\S]*?" : Regex.Escape(parametersSignature); + string pattern = + @"(?m)^[\t ]*(?:\[[^\n\]]+\][\t ]*\n)*[\t ]*" + + @"(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial|readonly|volatile|event|abstract|ref|in|out)\s+)*" + + rtPattern + @"[\t ]+" + namePattern + @"\s*\(" + paramsPattern + @"\)"; + + string slice = source.Substring(searchStart, searchEnd - searchStart); + var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline); + if (!headerMatch.Success) + { + why = $"method '{methodName}' header not found in class"; return false; + } + int headerIndex = searchStart + headerMatch.Index; + + // Optional attributes filter: look upward from headerIndex for contiguous attribute lines + if (!string.IsNullOrEmpty(attributesContains)) + { + int attrScanStart = headerIndex; + while (attrScanStart > searchStart) + { + int prevNl = source.LastIndexOf('\n', attrScanStart - 1); + if (prevNl < 0 || prevNl < searchStart) break; + string prevLine = source.Substring(prevNl + 1, attrScanStart - (prevNl + 1)); + if (prevLine.TrimStart().StartsWith("[")) { attrScanStart = prevNl; continue; } + break; + } + string attrBlock = source.Substring(attrScanStart, headerIndex - attrScanStart); + if (attrBlock.IndexOf(attributesContains, StringComparison.Ordinal) < 0) + { + why = $"method '{methodName}' found but attributes filter did not match"; return false; + } + } + + // backtrack to the very start of header/attributes to include in span + int lineStart = headerIndex; + while (lineStart > searchStart && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + // If previous lines are attributes, include them + int attrStart = lineStart; + int probe = lineStart - 1; + while (probe > searchStart) + { + int prevNl = source.LastIndexOf('\n', probe); + if (prevNl < 0 || prevNl < searchStart) break; + string prev = source.Substring(prevNl + 1, attrStart - (prevNl + 1)); + if (prev.TrimStart().StartsWith("[")) { attrStart = prevNl + 1; probe = prevNl - 1; } + else break; + } + + // 2) Walk from the end of signature to detect body style ('{' or '=> ...;') and compute end + int i = headerIndex; + int parenDepth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '(') parenDepth++; + if (c == ')') { parenDepth--; if (parenDepth == 0) { i++; break; } } + } + + // After params: detect expression-bodied or block-bodied + // Skip whitespace/comments + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + if (i < searchEnd - 1 && source[i] == '=' && source[i + 1] == '>') + { + // expression-bodied method: seek to terminating semicolon + int j = i; + bool done = false; + while (j < searchEnd) + { + char c = source[j]; + if (c == ';') { done = true; break; } + j++; + } + if (!done) { why = "unterminated expression-bodied method"; return false; } + start = attrStart; length = (j - attrStart) + 1; return true; + } + + if (i >= searchEnd || source[i] != '{') { why = "no opening brace after method signature"; return false; } + + int depth = 0; inStr = false; inChar = false; inSL = false; inML = false; esc = false; + int startSpan = attrStart; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow in method"; return false; } + } + } + why = "unterminated method block"; return false; + } + + private static int IndexOfTokenWithin(string s, string token, int start, int end) + { + int idx = s.IndexOf(token, start, StringComparison.Ordinal); + return (idx >= 0 && idx < end) ? idx : -1; + } + + private static bool TryFindClassInsertionPoint(string source, int classStart, int classLength, string position, out int insertAt, out string why) + { + insertAt = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + if (position == "start") + { + // find first '{' after class header, insert just after with a newline + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + insertAt = i + 1; return true; + } + else // end + { + // walk to matching closing brace of class and insert just before it + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { insertAt = i; return true; } + if (depth < 0) { why = "brace underflow while scanning class"; return false; } + } + } + why = "could not find class closing brace"; return false; + } + } + + private static int IndexOfClassToken(string s, string className) + { + // simple token search; could be tightened with Regex for word boundaries + var pattern = "class " + className; + return s.IndexOf(pattern, StringComparison.Ordinal); + } + + private static bool AppearsWithinNamespaceHeader(string s, int pos, string ns) + { + int from = Math.Max(0, pos - 2000); + var slice = s.Substring(from, pos - from); + return slice.Contains("namespace " + ns); + } + /// /// Generates basic C# script content based on name and type. /// From 73d212fc9c6d980d0f2f8b3b77dcbafd11327f05 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Fri, 15 Aug 2025 22:45:35 -0700 Subject: [PATCH 68/69] Unity MCP: prefer micro-edits & resources; add script_apply_edits priority and server apply_text_edits/validate; add resources list/read; deprecate manage_script read/update/edit; remove stdout prints; tweak connection handshake logging --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 308 ++++++++++++++++-- .../UnityMcpServer~/src/pyrightconfig.json | 4 + UnityMcpBridge/UnityMcpServer~/src/server.py | 75 ++++- .../UnityMcpServer~/src/tools/__init__.py | 7 +- .../src/tools/manage_script.py | 10 +- .../src/tools/manage_script_edits.py | 148 +++++++++ .../UnityMcpServer~/src/unity_connection.py | 15 +- test_unity_socket_framing.py | 88 +++++ 8 files changed, 613 insertions(+), 42 deletions(-) create mode 100644 UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json create mode 100644 UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py create mode 100644 test_unity_socket_framing.py diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 7c9861a5..d2df4584 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -56,7 +56,14 @@ public static class ManageScript private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, out string relPathSafe) { string assets = Application.dataPath.Replace('\\', '/'); - string targetDir = Path.Combine(assets, (relDir ?? "Scripts")).Replace('\\', '/'); + + // Normalize caller path: allow both "Scripts/..." and "Assets/Scripts/..." + string rel = (relDir ?? "Scripts").Replace('\\', '/').Trim(); + if (string.IsNullOrEmpty(rel)) rel = "Scripts"; + if (rel.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) rel = rel.Substring(7); + rel = rel.TrimStart('/'); + + string targetDir = Path.Combine(assets, rel).Replace('\\', '/'); string full = Path.GetFullPath(targetDir).Replace('\\', '/'); bool underAssets = full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase) @@ -178,17 +185,40 @@ public static object HandleCommand(JObject @params) namespaceName ); case "read": - return ReadScript(fullPath, relativePath); + return Response.Error("Deprecated: reads are resources now. Use resources/read with a unity://path or unity://script URI."); case "update": - return UpdateScript(fullPath, relativePath, name, contents); + return Response.Error("Deprecated: use apply_text_edits (small, line/col edits) rather than whole-file replace."); case "delete": return DeleteScript(fullPath, relativePath); - case "edit": + case "apply_text_edits": { var edits = @params["edits"] as JArray; - var options = @params["options"] as JObject; - return EditScript(fullPath, relativePath, name, edits, options); + string precondition = @params["precondition_sha256"]?.ToString(); // optional, currently ignored here + return ApplyTextEdits(fullPath, relativePath, name, edits); + } + case "validate": + { + string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; + var chosen = level switch + { + "basic" => ValidationLevel.Basic, + "strict" => ValidationLevel.Strict, + _ => ValidationLevel.Standard + }; + string fileText; + try { fileText = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diags); + var result = new + { + isValid = ok, + diagnostics = diags ?? Array.Empty() + }; + return ok ? Response.Success("Validation completed.", result) : Response.Error("Validation failed.", result); } + case "edit": + return Response.Error("Deprecated: use apply_text_edits. Structured 'edit' mode has been retired in favor of simple text edits."); default: return Response.Error( $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." @@ -390,6 +420,108 @@ string contents } } + /// + /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. + /// + private static object ApplyTextEdits( + string fullPath, + string relativePath, + string name, + JArray edits) + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + if (edits == null || edits.Count == 0) + return Response.Error("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + // Convert edits to absolute index ranges + var spans = new List<(int start, int end, string text)>(); + foreach (var e in edits) + { + try + { + int sl = Math.Max(1, e.Value("startLine")); + int sc = Math.Max(1, e.Value("startCol")); + int el = Math.Max(1, e.Value("endLine")); + int ec = Math.Max(1, e.Value("endCol")); + string newText = e.Value("newText") ?? string.Empty; + + if (!TryIndexFromLineCol(original, sl, sc, out int sidx)) + return Response.Error($"apply_text_edits: start out of range (line {sl}, col {sc})"); + if (!TryIndexFromLineCol(original, el, ec, out int eidx)) + return Response.Error($"apply_text_edits: end out of range (line {el}, col {ec})"); + if (eidx < sidx) (sidx, eidx) = (eidx, sidx); + + spans.Add((sidx, eidx, newText)); + } + catch (Exception ex) + { + return Response.Error($"Invalid edit payload: {ex.Message}"); + } + } + + // Ensure non-overlap and apply from back to front + spans = spans.OrderByDescending(t => t.start).ToList(); + for (int i = 1; i < spans.Count; i++) + { + if (spans[i].end > spans[i - 1].start) + return Response.Error("Edits overlap; split into separate calls or adjust ranges."); + } + + string working = original; + foreach (var sp in spans) + { + working = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + } + + // Validate result + var level = GetValidationLevelFromGUI(); + if (!ValidateScriptSyntax(working, level, out var errors)) + return Response.Error("Script validation failed:\n" + string.Join("\n", errors ?? Array.Empty())); + + // Atomic write and schedule refresh + try + { + var enc = System.Text.Encoding.UTF8; + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + string backup = fullPath + ".bak"; + try { File.Replace(tmp, fullPath, backup); } + catch (PlatformNotSupportedException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } + catch (IOException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } + + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + return Response.Success($"Applied {spans.Count} text edit(s) to '{relativePath}'.", new { path = relativePath, editsApplied = spans.Count, scheduledRefresh = true }); + } + catch (Exception ex) + { + return Response.Error($"Failed to write edits: {ex.Message}"); + } + } + + private static bool TryIndexFromLineCol(string text, int line1, int col1, out int index) + { + // 1-based line/col to absolute index (0-based), col positions are counted in code points + int line = 1, col = 1; + for (int i = 0; i <= text.Length; i++) + { + if (line == line1 && col == col1) + { + index = i; + return true; + } + if (i == text.Length) break; + char c = text[i]; + if (c == '\n') { line++; col = 1; } + else { col++; } + } + index = -1; return false; + } + private static object DeleteScript(string fullPath, string relativePath) { if (!File.Exists(fullPath)) @@ -448,6 +580,12 @@ private static object EditScript( try { var replacements = new List<(int start, int length, string text)>(); + int appliedCount = 0; + + // Apply mode: atomic (default) computes all spans against original and applies together. + // Sequential applies each edit immediately to the current working text (useful for dependent edits). + string applyMode = options?["applyMode"]?.ToString()?.ToLowerInvariant(); + bool applySequentially = applyMode == "sequential"; foreach (var e in edits) { @@ -473,7 +611,15 @@ private static object EditScript( if (!ValidateClassSnippet(replacement, className, out var vErr)) return Response.Error($"Replacement snippet invalid: {vErr}"); - replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); + if (applySequentially) + { + working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); + } break; } @@ -487,7 +633,15 @@ private static object EditScript( if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) return Response.Error($"delete_class failed: {why}"); - replacements.Add((s, l, string.Empty)); + if (applySequentially) + { + working = working.Remove(s, l); + appliedCount++; + } + else + { + replacements.Add((s, l, string.Empty)); + } break; } @@ -509,9 +663,24 @@ private static object EditScript( return Response.Error($"replace_method failed to locate class: {whyClass}"); if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) - return Response.Error($"replace_method failed: {whyMethod}"); + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return Response.Error($"replace_method failed: {whyMethod}.{hint}"); + } - replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); + if (applySequentially) + { + working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); + } break; } @@ -531,9 +700,24 @@ private static object EditScript( return Response.Error($"delete_method failed to locate class: {whyClass}"); if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) - return Response.Error($"delete_method failed: {whyMethod}"); + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return Response.Error($"delete_method failed: {whyMethod}.{hint}"); + } - replacements.Add((mStart, mLen, string.Empty)); + if (applySequentially) + { + working = working.Remove(mStart, mLen); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, string.Empty)); + } break; } @@ -561,14 +745,30 @@ private static object EditScript( return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); int insAt = aStart + aLen; string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); - replacements.Add((insAt, 0, text)); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } } else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) return Response.Error($"insert_method failed: {whyIns}"); else { string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); - replacements.Add((insAt, 0, text)); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } } break; } @@ -578,11 +778,15 @@ private static object EditScript( } } - if (HasOverlaps(replacements)) - return Response.Error("Edits overlap; split into separate calls or adjust targets."); + if (!applySequentially) + { + if (HasOverlaps(replacements)) + return Response.Error("Edits overlap; split into separate calls or adjust targets."); - foreach (var r in replacements.OrderByDescending(r => r.start)) - working = working.Remove(r.start, r.length).Insert(r.start, r.text); + foreach (var r in replacements.OrderByDescending(r => r.start)) + working = working.Remove(r.start, r.length).Insert(r.start, r.text); + appliedCount = replacements.Count; + } // Validate result using override from options if provided; otherwise GUI strictness var level = GetValidationLevelFromGUI(); @@ -621,8 +825,8 @@ private static object EditScript( bool immediate = refreshMode == "immediate" || refreshMode == "sync"; var ok = Response.Success( - $"Applied {replacements.Count} structured edit(s) to '{relativePath}'.", - new { path = relativePath, editsApplied = replacements.Count, scheduledRefresh = !immediate } + $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", + new { path = relativePath, editsApplied = appliedCount, scheduledRefresh = !immediate } ); if (immediate) @@ -796,9 +1000,9 @@ private static bool TryComputeMethodSpan( string namePattern = Regex.Escape(methodName); string paramsPattern = string.IsNullOrEmpty(parametersSignature) ? @"[\s\S]*?" : Regex.Escape(parametersSignature); string pattern = - @"(?m)^[\t ]*(?:\[[^\n\]]+\][\t ]*\n)*[\t ]*" + + @"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*" + @"(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial|readonly|volatile|event|abstract|ref|in|out)\s+)*" + - rtPattern + @"[\t ]+" + namePattern + @"\s*\(" + paramsPattern + @"\)"; + rtPattern + @"[\t ]+" + namePattern + @"\s*(?:<[^>]+>)?\s*\(" + paramsPattern + @"\)"; string slice = source.Substring(searchStart, searchEnd - searchStart); var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline); @@ -843,7 +1047,13 @@ private static bool TryComputeMethodSpan( } // 2) Walk from the end of signature to detect body style ('{' or '=> ...;') and compute end - int i = headerIndex; + // Find the '(' that belongs to the method signature, not attributes + int nameTokenIdx = IndexOfTokenWithin(source, methodName, headerIndex, searchEnd); + if (nameTokenIdx < 0) { why = $"method '{methodName}' token not found after header"; return false; } + int sigOpenParen = IndexOfTokenWithin(source, "(", nameTokenIdx, searchEnd); + if (sigOpenParen < 0) { why = "method parameter list '(' not found"; return false; } + + int i = sigOpenParen; int parenDepth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; for (; i < searchEnd; i++) { @@ -875,6 +1085,58 @@ private static bool TryComputeMethodSpan( break; } + // Tolerate generic constraints between params and body: multiple 'where T : ...' + for (;;) + { + // Skip whitespace/comments before checking for 'where' + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + // Check word-boundary 'where' + bool hasWhere = false; + if (i + 5 <= searchEnd) + { + hasWhere = source[i] == 'w' && source[i + 1] == 'h' && source[i + 2] == 'e' && source[i + 3] == 'r' && source[i + 4] == 'e'; + if (hasWhere) + { + // Left boundary + if (i - 1 >= 0) + { + char lb = source[i - 1]; + if (char.IsLetterOrDigit(lb) || lb == '_') hasWhere = false; + } + // Right boundary + if (hasWhere && i + 5 < searchEnd) + { + char rb = source[i + 5]; + if (char.IsLetterOrDigit(rb) || rb == '_') hasWhere = false; + } + } + } + if (!hasWhere) break; + + // Advance past the entire where-constraint clause until we hit '{' or '=>' or ';' + i += 5; // past 'where' + while (i < searchEnd) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (c == '{' || c == ';' || (c == '=' && n == '>')) break; + // Skip comments inline + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + i++; + } + } + + // Re-check for expression-bodied after constraints if (i < searchEnd - 1 && source[i] == '=' && source[i + 1] == '>') { // expression-bodied method: seek to terminating semicolon diff --git a/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json b/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json new file mode 100644 index 00000000..cfa4ff8c --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json @@ -0,0 +1,4 @@ +{ + "typeCheckingMode": "basic", + "reportMissingImports": "none" +} diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index 52633ef4..88add06d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -1,5 +1,6 @@ from mcp.server.fastmcp import FastMCP, Context, Image import logging +import sys from logging.handlers import RotatingFileHandler from dataclasses import dataclass from contextlib import asynccontextmanager @@ -9,12 +10,20 @@ from unity_connection import get_unity_connection, UnityConnection from pathlib import Path -# Configure logging using settings from config -logging.basicConfig( - level=getattr(logging, config.log_level), - format=config.log_format -) +# Configure logging: strictly stderr/file only (never stdout) +stderr_handler = logging.StreamHandler(stream=sys.stderr) +stderr_handler.setFormatter(logging.Formatter(config.log_format)) + +handlers = [stderr_handler] logger = logging.getLogger("unity-mcp-server") +logger.setLevel(getattr(logging, config.log_level)) +for h in list(logger.handlers): + logger.removeHandler(h) +for h in list(logging.getLogger().handlers): + logging.getLogger().removeHandler(h) +logger.addHandler(stderr_handler) +logging.getLogger().addHandler(stderr_handler) +logging.getLogger().setLevel(getattr(logging, config.log_level)) # File logging to avoid stdout interference with MCP stdio try: @@ -84,6 +93,62 @@ def asset_creation_strategy() -> str: "- Always include a camera and main light in your scenes.\\n" ) +# Resources support: list and read Unity scripts/files +@mcp.capabilities(resources={"listChanged": True}) +class _: + pass + +import os +import hashlib + +def _unity_assets_root() -> str: + # Heuristic: from the Unity project root (one level up from Library/ProjectSettings), 'Assets' + # Here, assume server runs from repo; let clients pass absolute paths under project too. + return None + +def _safe_path(uri: str) -> str | None: + # URIs: unity://path/Assets/... or file:///absolute + if uri.startswith("unity://path/"): + p = uri[len("unity://path/"):] + return p + if uri.startswith("file://"): + return uri[len("file://"):] + # Minimal tolerance for plain Assets/... paths + if uri.startswith("Assets/"): + return uri + return None + +@mcp.resource.list() +def list_resources(ctx: Context) -> list[dict]: + # Lightweight: expose only C# under Assets by default + assets = [] + try: + root = os.getcwd() + for base, _, files in os.walk(os.path.join(root, "Assets")): + for f in files: + if f.endswith(".cs"): + rel = os.path.relpath(os.path.join(base, f), root).replace("\\", "/") + assets.append({ + "uri": f"unity://path/{rel}", + "name": os.path.basename(rel) + }) + except Exception: + pass + return assets + +@mcp.resource.read() +def read_resource(ctx: Context, uri: str) -> dict: + path = _safe_path(uri) + if not path or not os.path.exists(path): + return {"mimeType": "text/plain", "text": f"Resource not found: {uri}"} + try: + with open(path, "r", encoding="utf-8") as f: + text = f.read() + sha = hashlib.sha256(text.encode("utf-8")).hexdigest() + return {"mimeType": "text/plain", "text": text, "metadata": {"sha256": sha}} + except Exception as e: + return {"mimeType": "text/plain", "text": f"Error reading resource: {e}"} + # Run the server if __name__ == "__main__": mcp.run(transport='stdio') diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index 4d8d63cf..91ee9495 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -1,3 +1,4 @@ +from .manage_script_edits import register_manage_script_edits_tools from .manage_script import register_manage_script_tools from .manage_scene import register_manage_scene_tools from .manage_editor import register_manage_editor_tools @@ -9,7 +10,9 @@ def register_all_tools(mcp): """Register all refactored tools with the MCP server.""" - print("Registering Unity MCP Server refactored tools...") + # Note: Do not print to stdout; Claude treats stdout as MCP JSON. Use logging. + # Prefer the surgical edits tool so LLMs discover it first + register_manage_script_edits_tools(mcp) register_manage_script_tools(mcp) register_manage_scene_tools(mcp) register_manage_editor_tools(mcp) @@ -18,4 +21,4 @@ def register_all_tools(mcp): register_manage_shader_tools(mcp) register_read_console_tools(mcp) register_execute_menu_item_tools(mcp) - print("Unity MCP Server tool registration complete.") + # Do not print to stdout here either. diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index a41fb85c..af44a446 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -19,8 +19,10 @@ def manage_script( script_type: str, namespace: str ) -> Dict[str, Any]: - """Manages C# scripts in Unity (create, read, update, delete). - Make reference variables public for easier access in the Unity Editor. + """Manage C# scripts in Unity. + + IMPORTANT: + - This router is minimized. Use resources/read for file content and 'script_apply_edits' for changes. Args: action: Operation ('create', 'read', 'update', 'delete'). @@ -34,6 +36,10 @@ def manage_script( Dictionary with results ('success', 'message', 'data'). """ try: + # Deprecate full-file update path entirely + if action == 'update': + return {"success": False, "message": "Deprecated: use script_apply_edits (line/col edits) or resources/read + small edits."} + # Prepare parameters for Unity params = { "action": action, diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py new file mode 100644 index 00000000..9cb746df --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -0,0 +1,148 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any, List +import base64 +import re +from unity_connection import send_command_with_retry + + +def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str: + text = original_text + for edit in edits or []: + op = ( + (edit.get("op") + or edit.get("operation") + or edit.get("type") + or edit.get("mode") + or "") + .strip() + .lower() + ) + + if not op: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError( + f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)." + ) + + if op == "prepend": + prepend_text = edit.get("text", "") + text = (prepend_text if prepend_text.endswith("\n") else prepend_text + "\n") + text + elif op == "append": + append_text = edit.get("text", "") + if not text.endswith("\n"): + text += "\n" + text += append_text + if not text.endswith("\n"): + text += "\n" + elif op == "anchor_insert": + anchor = edit.get("anchor", "") + position = (edit.get("position") or "before").lower() + insert_text = edit.get("text", "") + flags = re.MULTILINE + m = re.search(anchor, text, flags) + if not m: + if edit.get("allow_noop", True): + continue + raise RuntimeError(f"anchor not found: {anchor}") + idx = m.start() if position == "before" else m.end() + text = text[:idx] + insert_text + text[idx:] + elif op == "replace_range": + start_line = int(edit.get("startLine", 1)) + end_line = int(edit.get("endLine", start_line)) + replacement = edit.get("text", "") + lines = text.splitlines(keepends=True) + if start_line < 1 or end_line < start_line or end_line > len(lines): + raise RuntimeError("replace_range out of bounds") + a = start_line - 1 + b = end_line + rep = replacement + if rep and not rep.endswith("\n"): + rep += "\n" + text = "".join(lines[:a]) + rep + "".join(lines[b:]) + elif op == "regex_replace": + pattern = edit.get("pattern", "") + repl = edit.get("replacement", "") + count = int(edit.get("count", 0)) # 0 = replace all + flags = re.MULTILINE + if edit.get("ignore_case"): + flags |= re.IGNORECASE + text = re.sub(pattern, repl, text, count=count, flags=flags) + else: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError(f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") + return text + + +def register_manage_script_edits_tools(mcp: FastMCP): + @mcp.tool(description=( + "Apply targeted edits to an existing C# script WITHOUT replacing the whole file. " + "Preferred for inserts/patches. Supports ops: anchor_insert, prepend, append, " + "replace_range, regex_replace. For full-file creation, use manage_script(create)." + )) + def script_apply_edits( + ctx: Context, + name: str, + path: str, + edits: List[Dict[str, Any]], + options: Dict[str, Any] | None = None, + script_type: str = "MonoBehaviour", + namespace: str = "", + ) -> Dict[str, Any]: + # If the edits request structured class/method ops, route directly to Unity's 'edit' action + for e in edits or []: + op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method"): + params: Dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": edits, + } + if options is not None: + params["options"] = options + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + # 1) read from Unity + read_resp = send_command_with_retry("manage_script", { + "action": "read", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + }) + if not isinstance(read_resp, dict) or not read_resp.get("success"): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + + data = read_resp.get("data") or read_resp.get("result", {}).get("data") or {} + contents = data.get("contents") + if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): + contents = base64.b64decode(data["encodedContents"]).decode("utf-8") + if contents is None: + return {"success": False, "message": "No contents returned from Unity read."} + + # 2) apply edits locally + try: + new_contents = _apply_edits_locally(contents, edits) + except Exception as e: + return {"success": False, "message": f"Edit application failed: {e}"} + + # 3) update to Unity + params: Dict[str, Any] = { + "action": "update", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "encodedContents": base64.b64encode(new_contents.encode("utf-8")).decode("ascii"), + "contentsEncoded": True, + } + if options is not None: + params["options"] = options + write_resp = send_command_with_retry("manage_script", params) + return write_resp if isinstance(write_resp, dict) else {"success": False, "message": str(write_resp)} + + + diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index bc602040..f04fb430 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -38,7 +38,7 @@ def connect(self) -> bool: try: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((self.host, self.port)) - logger.info(f"Connected to Unity at {self.host}:{self.port}") + logger.debug(f"Connected to Unity at {self.host}:{self.port}") # Strict handshake: require FRAMING=1 try: @@ -47,7 +47,7 @@ def connect(self) -> bool: text = greeting.decode('ascii', errors='ignore') if greeting else '' if 'FRAMING=1' in text: self.use_framing = True - logger.info('Unity MCP handshake received: FRAMING=1 (strict)') + logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') else: raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') finally: @@ -188,15 +188,10 @@ def read_status_file() -> dict | None: for attempt in range(attempts + 1): try: - # Ensure connected + # Ensure connected (perform handshake each time so framing stays correct) if not self.sock: - # During retries use short connect timeout - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.settimeout(1.0) - self.sock.connect((self.host, self.port)) - # restore steady-state timeout for receive - self.sock.settimeout(config.connection_timeout) - logger.info(f"Connected to Unity at {self.host}:{self.port}") + if not self.connect(): + raise Exception("Could not connect to Unity") # Build payload if command_type == 'ping': diff --git a/test_unity_socket_framing.py b/test_unity_socket_framing.py new file mode 100644 index 00000000..b0e179c9 --- /dev/null +++ b/test_unity_socket_framing.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +import socket, struct, json, sys + +HOST = "127.0.0.1" +PORT = 6400 +SIZE_MB = int(sys.argv[1]) if len(sys.argv) > 1 else 5 # e.g., 5 or 10 +FILL = "R" + +def recv_exact(sock, n): + buf = bytearray(n) + view = memoryview(buf) + off = 0 + while off < n: + r = sock.recv_into(view[off:]) + if r == 0: + raise RuntimeError("socket closed") + off += r + return bytes(buf) + +def is_valid_json(b): + try: + json.loads(b.decode("utf-8")) + return True + except Exception: + return False + +def recv_legacy_json(sock, timeout=60): + sock.settimeout(timeout) + chunks = [] + while True: + chunk = sock.recv(65536) + if not chunk: + data = b"".join(chunks) + if not data: + raise RuntimeError("no data, socket closed") + return data + chunks.append(chunk) + data = b"".join(chunks) + if data.strip() == b"ping": + return data + if is_valid_json(data): + return data + +def main(): + body = { + "type": "read_console", + "params": { + "action": "get", + "types": ["all"], + "count": 1000, + "format": "detailed", + "includeStacktrace": True, + "filterText": FILL * (SIZE_MB * 1024 * 1024) + } + } + body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") + + with socket.create_connection((HOST, PORT), timeout=5) as s: + s.settimeout(2) + # Read optional greeting + try: + greeting = s.recv(256) + except Exception: + greeting = b"" + greeting_text = greeting.decode("ascii", errors="ignore").strip() + print(f"Greeting: {greeting_text or '(none)'}") + + framing = "FRAMING=1" in greeting_text + print(f"Using framing? {framing}") + + s.settimeout(120) + if framing: + header = struct.pack(">Q", len(body_bytes)) + s.sendall(header + body_bytes) + resp_len = struct.unpack(">Q", recv_exact(s, 8))[0] + print(f"Response framed length: {resp_len}") + resp = recv_exact(s, resp_len) + else: + s.sendall(body_bytes) + resp = recv_legacy_json(s) + + print(f"Response bytes: {len(resp)}") + print(f"Response head: {resp[:120].decode('utf-8','ignore')}") + +if __name__ == "__main__": + main() + + From a12dcab7b0dcffa184bb9964831a95947f08447a Mon Sep 17 00:00:00 2001 From: dsarno Date: Sat, 16 Aug 2025 03:49:52 -0700 Subject: [PATCH 69/69] test: add initial script and asset edit tests --- UnityMcpBridge/Editor/Tools/ManageScript.cs | 178 ++++++++++++++++-- .../UnityMcpServer~/src/tools/manage_asset.py | 2 +- .../src/tools/manage_script.py | 124 ++++++++++-- test_unity_socket_framing.py | 5 +- tests/test_script_tools.py | 123 ++++++++++++ 5 files changed, 394 insertions(+), 38 deletions(-) create mode 100644 tests/test_script_tools.py diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index d2df4584..0d2fae60 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -8,10 +8,12 @@ using UnityEngine; using UnityMcpBridge.Editor.Helpers; using System.Threading; +using System.Security.Cryptography; #if USE_ROSLYN using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Formatting; #endif #if UNITY_EDITOR @@ -193,12 +195,12 @@ public static object HandleCommand(JObject @params) case "apply_text_edits": { var edits = @params["edits"] as JArray; - string precondition = @params["precondition_sha256"]?.ToString(); // optional, currently ignored here - return ApplyTextEdits(fullPath, relativePath, name, edits); + string precondition = @params["precondition_sha256"]?.ToString(); + return ApplyTextEdits(fullPath, relativePath, name, edits, precondition); } case "validate": { - string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; + string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "basic"; var chosen = level switch { "basic" => ValidationLevel.Basic, @@ -209,13 +211,19 @@ public static object HandleCommand(JObject @params) try { fileText = File.ReadAllText(fullPath); } catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } - bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diags); - var result = new + bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); + var diags = (diagsRaw ?? Array.Empty()).Select(s => { - isValid = ok, - diagnostics = diags ?? Array.Empty() - }; - return ok ? Response.Success("Validation completed.", result) : Response.Error("Validation failed.", result); + var m = Regex.Match(s, @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$"); + string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; + string message = m.Success ? m.Groups[2].Value : s; + int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; + return new { line = lineNum, col = 0, severity, message }; + }).ToArray(); + + var result = new { diagnostics = diags }; + return ok ? Response.Success("Validation completed.", result) + : Response.Error("Validation failed.", result); } case "edit": return Response.Error("Deprecated: use apply_text_edits. Structured 'edit' mode has been retired in favor of simple text edits."); @@ -299,9 +307,10 @@ string namespaceName try { File.Delete(tmp); } catch { } } + var uri = $"unity://path/{relativePath}"; var ok = Response.Success( $"Script '{name}.cs' created successfully at '{relativePath}'.", - new { path = relativePath, scheduledRefresh = true } + new { uri, scheduledRefresh = true } ); // Schedule heavy work AFTER replying @@ -423,11 +432,14 @@ string contents /// /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. /// + private const int MaxEditPayloadBytes = 15 * 1024; + private static object ApplyTextEdits( string fullPath, string relativePath, string name, - JArray edits) + JArray edits, + string preconditionSha256) { if (!File.Exists(fullPath)) return Response.Error($"Script not found at '{relativePath}'."); @@ -438,8 +450,15 @@ private static object ApplyTextEdits( try { original = File.ReadAllText(fullPath); } catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + string currentSha = ComputeSha256(original); + if (!string.IsNullOrEmpty(preconditionSha256) && !preconditionSha256.Equals(currentSha, StringComparison.OrdinalIgnoreCase)) + { + return Response.Error("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); + } + // Convert edits to absolute index ranges var spans = new List<(int start, int end, string text)>(); + int totalBytes = 0; foreach (var e in edits) { try @@ -457,6 +476,7 @@ private static object ApplyTextEdits( if (eidx < sidx) (sidx, eidx) = (eidx, sidx); spans.Add((sidx, eidx, newText)); + totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); } catch (Exception ex) { @@ -464,6 +484,11 @@ private static object ApplyTextEdits( } } + if (totalBytes > MaxEditPayloadBytes) + { + return Response.Error("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); + } + // Ensure non-overlap and apply from back to front spans = spans.OrderByDescending(t => t.start).ToList(); for (int i = 1; i < spans.Count; i++) @@ -478,10 +503,40 @@ private static object ApplyTextEdits( working = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); } - // Validate result - var level = GetValidationLevelFromGUI(); - if (!ValidateScriptSyntax(working, level, out var errors)) - return Response.Error("Script validation failed:\n" + string.Join("\n", errors ?? Array.Empty())); + if (!CheckBalancedDelimiters(working, out int line, out char expected)) + { + int startLine = Math.Max(1, line - 5); + int endLine = line + 5; + string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; + return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString() }); + } + +#if USE_ROSLYN + var tree = CSharpSyntaxTree.ParseText(working); + var diagnostics = tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Take(3) + .Select(d => new { + line = d.Location.GetLineSpan().StartLinePosition.Line + 1, + col = d.Location.GetLineSpan().StartLinePosition.Character + 1, + code = d.Id, + message = d.GetMessage() + }).ToArray(); + if (diagnostics.Length > 0) + { + return Response.Error("syntax_error", new { status = "syntax_error", diagnostics }); + } + + // Optional formatting + try + { + var root = tree.GetRoot(); + var workspace = new AdhocWorkspace(); + root = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, workspace); + working = root.ToFullString(); + } + catch { } +#endif + + string newSha = ComputeSha256(working); // Atomic write and schedule refresh try @@ -495,7 +550,17 @@ private static object ApplyTextEdits( catch (IOException) { File.Copy(tmp, fullPath, true); try { File.Delete(tmp); } catch { } } ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); - return Response.Success($"Applied {spans.Count} text edit(s) to '{relativePath}'.", new { path = relativePath, editsApplied = spans.Count, scheduledRefresh = true }); + return Response.Success( + $"Applied {spans.Count} text edit(s) to '{relativePath}'.", + new + { + applied = spans.Count, + unchanged = 0, + sha256 = newSha, + uri = $"unity://path/{relativePath}", + scheduledRefresh = true + } + ); } catch (Exception ex) { @@ -522,6 +587,84 @@ private static bool TryIndexFromLineCol(string text, int line1, int col1, out in index = -1; return false; } + private static string ComputeSha256(string contents) + { + using (var sha = SHA256.Create()) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(contents); + var hash = sha.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + private static bool CheckBalancedDelimiters(string text, out int line, out char expected) + { + var braceStack = new Stack(); + var parenStack = new Stack(); + var bracketStack = new Stack(); + bool inString = false, inChar = false, inSingle = false, inMulti = false, escape = false; + line = 1; expected = '\0'; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + char next = i + 1 < text.Length ? text[i + 1] : '\0'; + + if (c == '\n') { line++; if (inSingle) inSingle = false; } + + if (escape) { escape = false; continue; } + + if (inString) + { + if (c == '\\') { escape = true; } + else if (c == '"') inString = false; + continue; + } + if (inChar) + { + if (c == '\\') { escape = true; } + else if (c == '\'') inChar = false; + continue; + } + if (inSingle) continue; + if (inMulti) + { + if (c == '*' && next == '/') { inMulti = false; i++; } + continue; + } + + if (c == '"') { inString = true; continue; } + if (c == '\'') { inChar = true; continue; } + if (c == '/' && next == '/') { inSingle = true; i++; continue; } + if (c == '/' && next == '*') { inMulti = true; i++; continue; } + + switch (c) + { + case '{': braceStack.Push(line); break; + case '}': + if (braceStack.Count == 0) { expected = '{'; return false; } + braceStack.Pop(); + break; + case '(': parenStack.Push(line); break; + case ')': + if (parenStack.Count == 0) { expected = '('; return false; } + parenStack.Pop(); + break; + case '[': bracketStack.Push(line); break; + case ']': + if (bracketStack.Count == 0) { expected = '['; return false; } + bracketStack.Pop(); + break; + } + } + + if (braceStack.Count > 0) { line = braceStack.Peek(); expected = '}'; return false; } + if (parenStack.Count > 0) { line = parenStack.Peek(); expected = ')'; return false; } + if (bracketStack.Count > 0) { line = bracketStack.Peek(); expected = ']'; return false; } + + return true; + } + private static object DeleteScript(string fullPath, string relativePath) { if (!File.Exists(fullPath)) @@ -537,7 +680,8 @@ private static object DeleteScript(string fullPath, string relativePath) { AssetDatabase.Refresh(); return Response.Success( - $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully." + $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", + new { deleted = true } ); } else diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index 19ac0c2e..ccafb047 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -76,4 +76,4 @@ async def manage_asset( # Use centralized async retry helper to avoid blocking the event loop result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) # Return the result obtained from Unity - return result if isinstance(result, dict) else {"success": False, "message": str(result)} \ No newline at end of file + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index af44a446..f7836da3 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -1,14 +1,95 @@ from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any +from typing import Dict, Any, List from unity_connection import get_unity_connection, send_command_with_retry from config import config import time import os import base64 + def register_manage_script_tools(mcp: FastMCP): """Register all script management tools with the MCP server.""" + def _split_uri(uri: str) -> tuple[str, str]: + if uri.startswith("unity://path/"): + path = uri[len("unity://path/") :] + elif uri.startswith("file://"): + path = uri[len("file://") :] + else: + path = uri + path = path.replace("\\", "/") + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + return name, directory + + @mcp.tool() + def apply_text_edits( + ctx: Context, + uri: str, + edits: List[Dict[str, Any]], + precondition_sha256: str | None = None, + ) -> Dict[str, Any]: + """Apply small text edits to a C# script identified by URI.""" + name, directory = _split_uri(uri) + params = { + "action": "apply_text_edits", + "name": name, + "path": directory, + "edits": edits, + "precondition_sha256": precondition_sha256, + } + params = {k: v for k, v in params.items() if v is not None} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + @mcp.tool() + def create_script( + ctx: Context, + path: str, + contents: str = "", + script_type: str | None = None, + namespace: str | None = None, + ) -> Dict[str, Any]: + """Create a new C# script at the given path.""" + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + params: Dict[str, Any] = { + "action": "create", + "name": name, + "path": directory, + "namespace": namespace, + "scriptType": script_type, + } + if contents is not None: + params["encodedContents"] = base64.b64encode(contents.encode("utf-8")).decode("utf-8") + params["contentsEncoded"] = True + params = {k: v for k, v in params.items() if v is not None} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + @mcp.tool() + def delete_script(ctx: Context, uri: str) -> Dict[str, Any]: + """Delete a C# script by URI.""" + name, directory = _split_uri(uri) + params = {"action": "delete", "name": name, "path": directory} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + @mcp.tool() + def validate_script( + ctx: Context, uri: str, level: str = "basic" + ) -> Dict[str, Any]: + """Validate a C# script and return diagnostics.""" + name, directory = _split_uri(uri) + params = { + "action": "validate", + "name": name, + "path": directory, + "level": level, + } + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + @mcp.tool() def manage_script( ctx: Context, @@ -17,12 +98,13 @@ def manage_script( path: str, contents: str, script_type: str, - namespace: str + namespace: str, ) -> Dict[str, Any]: - """Manage C# scripts in Unity. + """Compatibility router for legacy script operations. IMPORTANT: - - This router is minimized. Use resources/read for file content and 'script_apply_edits' for changes. + - Direct file reads should use resources/read. + - Edits should use apply_text_edits. Args: action: Operation ('create', 'read', 'update', 'delete'). @@ -38,7 +120,7 @@ def manage_script( try: # Deprecate full-file update path entirely if action == 'update': - return {"success": False, "message": "Deprecated: use script_apply_edits (line/col edits) or resources/read + small edits."} + return {"success": False, "message": "Deprecated: use apply_text_edits or resources/read + small edits."} # Prepare parameters for Unity params = { @@ -46,36 +128,40 @@ def manage_script( "name": name, "path": path, "namespace": namespace, - "scriptType": script_type + "scriptType": script_type, } - + # Base64 encode the contents if they exist to avoid JSON escaping issues if contents is not None: if action in ['create', 'update']: - # Encode content for safer transmission params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') params["contentsEncoded"] = True else: params["contents"] = contents - - # Remove None values so they don't get sent as null + params = {k: v for k, v in params.items() if v is not None} - # Send command via centralized retry helper response = send_command_with_retry("manage_script", params) - - # Process response from Unity + if isinstance(response, dict) and response.get("success"): - # If the response contains base64 encoded content, decode it if response.get("data", {}).get("contentsEncoded"): decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') response["data"]["contents"] = decoded_contents del response["data"]["encodedContents"] del response["data"]["contentsEncoded"] - - return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + return { + "success": True, + "message": response.get("message", "Operation successful."), + "data": response.get("data"), + } + return response if isinstance(response, dict) else { + "success": False, + "message": str(response), + } except Exception as e: - # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing script: {str(e)}"} \ No newline at end of file + return { + "success": False, + "message": f"Python error managing script: {str(e)}", + } diff --git a/test_unity_socket_framing.py b/test_unity_socket_framing.py index b0e179c9..c24064a1 100644 --- a/test_unity_socket_framing.py +++ b/test_unity_socket_framing.py @@ -3,7 +3,10 @@ HOST = "127.0.0.1" PORT = 6400 -SIZE_MB = int(sys.argv[1]) if len(sys.argv) > 1 else 5 # e.g., 5 or 10 +try: + SIZE_MB = int(sys.argv[1]) +except (IndexError, ValueError): + SIZE_MB = 5 # e.g., 5 or 10 FILL = "R" def recv_exact(sock, n): diff --git a/tests/test_script_tools.py b/tests/test_script_tools.py new file mode 100644 index 00000000..9b953a1a --- /dev/null +++ b/tests/test_script_tools.py @@ -0,0 +1,123 @@ +import sys +import pathlib +import importlib.util +import types +import pytest + +# add server src to path and load modules without triggering package imports +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +sys.path.insert(0, str(SRC)) + +# stub mcp.server.fastmcp to satisfy imports without full dependency +mcp_pkg = types.ModuleType("mcp") +server_pkg = types.ModuleType("mcp.server") +fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") + +class _Dummy: + pass + +fastmcp_pkg.FastMCP = _Dummy +fastmcp_pkg.Context = _Dummy +server_pkg.fastmcp = fastmcp_pkg +mcp_pkg.server = server_pkg +sys.modules.setdefault("mcp", mcp_pkg) +sys.modules.setdefault("mcp.server", server_pkg) +sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + +def load_module(path, name): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +manage_script_module = load_module(SRC / "tools" / "manage_script.py", "manage_script_module") +manage_asset_module = load_module(SRC / "tools" / "manage_asset.py", "manage_asset_module") + + +class DummyMCP: + def __init__(self): + self.tools = {} + + def tool(self): + def decorator(func): + self.tools[func.__name__] = func + return func + return decorator + +def setup_manage_script(): + mcp = DummyMCP() + manage_script_module.register_manage_script_tools(mcp) + return mcp.tools + +def setup_manage_asset(): + mcp = DummyMCP() + manage_asset_module.register_manage_asset_tools(mcp) + return mcp.tools + +def test_apply_text_edits_long_file(monkeypatch): + tools = setup_manage_script() + apply_edits = tools["apply_text_edits"] + captured = {} + + def fake_send(cmd, params): + captured["cmd"] = cmd + captured["params"] = params + return {"success": True} + + monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) + + edit = {"startLine": 1005, "startCol": 0, "endLine": 1005, "endCol": 5, "newText": "Hello"} + resp = apply_edits(None, "unity://path/Assets/Scripts/LongFile.cs", [edit]) + assert captured["cmd"] == "manage_script" + assert captured["params"]["action"] == "apply_text_edits" + assert captured["params"]["edits"][0]["startLine"] == 1005 + assert resp["success"] is True + +def test_sequential_edits_use_precondition(monkeypatch): + tools = setup_manage_script() + apply_edits = tools["apply_text_edits"] + calls = [] + + def fake_send(cmd, params): + calls.append(params) + return {"success": True, "sha256": f"hash{len(calls)}"} + + monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) + + edit1 = {"startLine": 1, "startCol": 0, "endLine": 1, "endCol": 0, "newText": "//header\n"} + resp1 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit1]) + edit2 = {"startLine": 2, "startCol": 0, "endLine": 2, "endCol": 0, "newText": "//second\n"} + resp2 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit2], precondition_sha256=resp1["sha256"]) + + assert calls[1]["precondition_sha256"] == resp1["sha256"] + assert resp2["sha256"] == "hash2" + +def test_manage_asset_prefab_modify_request(monkeypatch): + tools = setup_manage_asset() + manage_asset = tools["manage_asset"] + captured = {} + + async def fake_async(cmd, params, loop=None): + captured["cmd"] = cmd + captured["params"] = params + return {"success": True} + + monkeypatch.setattr(manage_asset_module, "async_send_command_with_retry", fake_async) + monkeypatch.setattr(manage_asset_module, "get_unity_connection", lambda: object()) + + async def run(): + resp = await manage_asset( + None, + action="modify", + path="Assets/Prefabs/Player.prefab", + properties={"hp": 100}, + ) + assert captured["cmd"] == "manage_asset" + assert captured["params"]["action"] == "modify" + assert captured["params"]["path"] == "Assets/Prefabs/Player.prefab" + assert captured["params"]["properties"] == {"hp": 100} + assert resp["success"] is True + + import asyncio + asyncio.run(run())