unity-mcp/UnityMcpBridge/Editor/UnityMcpBridge.cs

409 lines
15 KiB
C#
Raw Normal View History

2025-03-18 19:00:50 +08:00
using System;
using System.Collections.Generic;
using System.IO;
2025-03-18 19:00:50 +08:00
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
2025-04-08 19:22:24 +08:00
using UnityMcpBridge.Editor.Helpers;
using UnityMcpBridge.Editor.Models;
using UnityMcpBridge.Editor.Tools;
2025-03-18 19:00:50 +08:00
namespace UnityMcpBridge.Editor
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
[InitializeOnLoad]
public static partial class UnityMcpBridge
2025-03-18 22:01:51 +08:00
{
2025-03-20 19:24:31 +08:00
private static TcpListener listener;
private static bool isRunning = false;
private static readonly object lockObj = new();
private static Dictionary<
string,
(string commandJson, TaskCompletionSource<string> tcs)
> commandQueue = new();
private static readonly int unityPort = 6400; // Hardcoded port
2025-03-19 01:09:54 +08:00
2025-03-20 19:24:31 +08:00
public static bool IsRunning => isRunning;
2025-03-18 19:00:50 +08:00
2025-03-20 19:24:31 +08:00
public static bool FolderExists(string path)
{
if (string.IsNullOrEmpty(path))
2025-04-08 19:22:24 +08:00
{
2025-03-20 19:24:31 +08:00
return false;
2025-04-08 19:22:24 +08:00
}
2025-03-18 19:00:50 +08:00
2025-03-20 19:24:31 +08:00
if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase))
2025-04-08 19:22:24 +08:00
{
2025-03-20 19:24:31 +08:00
return true;
2025-04-08 19:22:24 +08:00
}
2025-03-18 19:00:50 +08:00
string fullPath = Path.Combine(
Application.dataPath,
2025-04-08 19:22:24 +08:00
path.StartsWith("Assets/") ? path[7..] : path
);
2025-03-20 19:24:31 +08:00
return Directory.Exists(fullPath);
}
2025-03-18 19:00:50 +08:00
static UnityMcpBridge()
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
Start();
EditorApplication.quitting += Stop;
}
2025-03-19 01:09:54 +08:00
2025-03-20 19:24:31 +08:00
public static void Start()
{
2025-04-08 19:22:24 +08:00
try
{
ServerInstaller.EnsureServerInstalled();
}
catch (Exception ex)
{
Debug.LogError($"Failed to ensure UnityMcpServer is installed: {ex.Message}");
}
if (isRunning)
2025-04-08 19:22:24 +08:00
{
return;
2025-04-08 19:22:24 +08:00
}
2025-03-20 19:24:31 +08:00
isRunning = true;
listener = new TcpListener(IPAddress.Loopback, unityPort);
listener.Start();
Task.Run(ListenerLoop);
EditorApplication.update += ProcessCommands;
}
2025-03-19 01:09:54 +08:00
2025-03-20 19:24:31 +08:00
public static void Stop()
{
if (!isRunning)
2025-04-08 19:22:24 +08:00
{
return;
2025-04-08 19:22:24 +08:00
}
2025-03-20 19:24:31 +08:00
isRunning = false;
listener.Stop();
EditorApplication.update -= ProcessCommands;
Debug.Log("UnityMCPBridge stopped.");
2025-03-18 19:00:50 +08:00
}
2025-03-20 19:24:31 +08:00
private static async Task ListenerLoop()
2025-03-18 19:00:50 +08:00
{
while (isRunning)
{
try
{
2025-04-08 19:22:24 +08:00
TcpClient client = await listener.AcceptTcpClientAsync();
2025-03-20 19:24:31 +08:00
// Enable basic socket keepalive
client.Client.SetSocketOption(
SocketOptionLevel.Socket,
SocketOptionName.KeepAlive,
true
);
2025-03-18 19:00:50 +08:00
2025-03-20 19:24:31 +08:00
// Set longer receive timeout to prevent quick disconnections
client.ReceiveTimeout = 60000; // 60 seconds
2025-03-18 19:00:50 +08:00
2025-03-20 19:24:31 +08:00
// Fire and forget each client connection
_ = HandleClientAsync(client);
2025-03-18 19:00:50 +08:00
}
catch (Exception ex)
{
if (isRunning)
2025-04-08 19:22:24 +08:00
{
Debug.LogError($"Listener error: {ex.Message}");
2025-04-08 19:22:24 +08:00
}
2025-03-18 19:00:50 +08:00
}
}
}
2025-03-20 19:24:31 +08:00
private static async Task HandleClientAsync(TcpClient client)
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
using (client)
2025-04-08 19:22:24 +08:00
using (NetworkStream stream = client.GetStream())
2025-03-18 19:00:50 +08:00
{
2025-04-08 19:22:24 +08:00
byte[] buffer = new byte[8192];
2025-03-20 19:24:31 +08:00
while (isRunning)
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
try
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0)
2025-04-08 19:22:24 +08:00
{
break; // Client disconnected
2025-04-08 19:22:24 +08:00
}
string commandText = System.Text.Encoding.UTF8.GetString(
buffer,
0,
bytesRead
);
2025-03-20 19:24:31 +08:00
string commandId = Guid.NewGuid().ToString();
2025-04-08 19:22:24 +08:00
TaskCompletionSource<string> tcs = new();
2025-03-20 19:24:31 +08:00
// Special handling for ping command to avoid JSON parsing
if (commandText.Trim() == "ping")
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
// Direct response to ping without going through JSON parsing
byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes(
2025-04-08 19:22:24 +08:00
/*lang=json,strict*/
"{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}"
);
2025-03-20 19:24:31 +08:00
await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length);
continue;
}
2025-03-18 19:00:50 +08:00
2025-03-20 19:24:31 +08:00
lock (lockObj)
{
commandQueue[commandId] = (commandText, tcs);
}
2025-03-18 19:00:50 +08:00
2025-03-20 19:24:31 +08:00
string response = await tcs.Task;
byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
await stream.WriteAsync(responseBytes, 0, responseBytes.Length);
}
catch (Exception ex)
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
Debug.LogError($"Client handler error: {ex.Message}");
break;
2025-03-18 19:00:50 +08:00
}
2025-03-20 19:24:31 +08:00
}
}
}
private static void ProcessCommands()
{
List<string> processedIds = new();
lock (lockObj)
{
2025-04-08 19:22:24 +08:00
foreach (
KeyValuePair<
string,
(string commandJson, TaskCompletionSource<string> tcs)
> kvp in commandQueue.ToList()
)
2025-03-20 19:24:31 +08:00
{
string id = kvp.Key;
string commandText = kvp.Value.commandJson;
2025-04-08 19:22:24 +08:00
TaskCompletionSource<string> tcs = kvp.Value.tcs;
2025-03-18 19:00:50 +08:00
2025-03-20 19:24:31 +08:00
try
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
// Special case handling
if (string.IsNullOrEmpty(commandText))
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
var emptyResponse = new
{
status = "error",
error = "Empty command received",
2025-03-20 19:24:31 +08:00
};
tcs.SetResult(JsonConvert.SerializeObject(emptyResponse));
processedIds.Add(id);
continue;
}
// Trim the command text to remove any whitespace
commandText = commandText.Trim();
// Non-JSON direct commands handling (like ping)
if (commandText == "ping")
{
var pingResponse = new
{
status = "success",
result = new { message = "pong" },
2025-03-20 19:24:31 +08:00
};
tcs.SetResult(JsonConvert.SerializeObject(pingResponse));
processedIds.Add(id);
continue;
}
// Check if the command is valid JSON before attempting to deserialize
if (!IsValidJson(commandText))
{
var invalidJsonResponse = new
{
status = "error",
error = "Invalid JSON format",
receivedText = commandText.Length > 50
2025-04-08 19:22:24 +08:00
? commandText[..50] + "..."
: commandText,
2025-03-20 19:24:31 +08:00
};
tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse));
processedIds.Add(id);
continue;
}
// Normal JSON command processing
2025-04-08 19:22:24 +08:00
Command command = JsonConvert.DeserializeObject<Command>(commandText);
2025-03-20 19:24:31 +08:00
if (command == null)
{
var nullCommandResponse = new
{
status = "error",
error = "Command deserialized to null",
details = "The command was valid JSON but could not be deserialized to a Command object",
2025-03-20 19:24:31 +08:00
};
tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse));
}
else
{
string responseJson = ExecuteCommand(command);
tcs.SetResult(responseJson);
}
2025-03-18 19:00:50 +08:00
}
2025-03-20 19:24:31 +08:00
catch (Exception ex)
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}");
var response = new
2025-03-18 19:00:50 +08:00
{
status = "error",
2025-03-20 19:24:31 +08:00
error = ex.Message,
commandType = "Unknown (error during processing)",
receivedText = commandText?.Length > 50
2025-04-08 19:22:24 +08:00
? commandText[..50] + "..."
: commandText,
2025-03-18 19:00:50 +08:00
};
2025-03-20 19:24:31 +08:00
string responseJson = JsonConvert.SerializeObject(response);
2025-03-18 19:00:50 +08:00
tcs.SetResult(responseJson);
}
2025-03-20 19:24:31 +08:00
processedIds.Add(id);
2025-03-18 19:00:50 +08:00
}
2025-04-08 19:22:24 +08:00
foreach (string id in processedIds)
2025-03-20 19:24:31 +08:00
{
commandQueue.Remove(id);
}
2025-03-18 19:00:50 +08:00
}
2025-03-20 19:24:31 +08:00
}
// Helper method to check if a string is valid JSON
private static bool IsValidJson(string text)
{
if (string.IsNullOrWhiteSpace(text))
2025-04-08 19:22:24 +08:00
{
2025-03-20 19:24:31 +08:00
return false;
2025-04-08 19:22:24 +08:00
}
2025-03-18 19:00:50 +08:00
2025-03-20 19:24:31 +08:00
text = text.Trim();
if (
(text.StartsWith("{") && text.EndsWith("}"))
|| // Object
(text.StartsWith("[") && text.EndsWith("]"))
) // Array
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
try
{
JToken.Parse(text);
return true;
}
catch
{
return false;
}
2025-03-18 19:00:50 +08:00
}
return false;
2025-03-20 19:24:31 +08:00
}
2025-03-18 19:00:50 +08:00
2025-03-20 19:24:31 +08:00
private static string ExecuteCommand(Command command)
2025-03-18 19:00:50 +08:00
{
try
{
2025-03-20 19:24:31 +08:00
if (string.IsNullOrEmpty(command.type))
{
var errorResponse = new
{
status = "error",
error = "Command type cannot be empty",
details = "A valid command type is required for processing",
2025-03-20 19:24:31 +08:00
};
return JsonConvert.SerializeObject(errorResponse);
}
2025-03-18 19:00:50 +08:00
2025-03-20 19:24:31 +08:00
// Handle ping command for connection verification
2025-03-31 03:58:01 +08:00
if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase))
2025-03-20 19:24:31 +08:00
{
var pingResponse = new
{
status = "success",
result = new { message = "pong" },
};
2025-03-20 19:24:31 +08:00
return JsonConvert.SerializeObject(pingResponse);
}
2025-03-18 19:00:50 +08:00
2025-03-31 03:58:01 +08:00
// Use JObject for parameters as the new handlers likely expect this
JObject paramsObject = command.@params ?? new JObject();
// Route command based on the new tool structure from the refactor plan
2025-03-20 19:24:31 +08:00
object result = command.type switch
2025-03-18 19:00:50 +08:00
{
2025-03-31 03:58:01 +08:00
// Maps the command type (tool name) to the corresponding handler's static HandleCommand method
// Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters
"manage_script" => ManageScript.HandleCommand(paramsObject),
"manage_scene" => ManageScene.HandleCommand(paramsObject),
"manage_editor" => ManageEditor.HandleCommand(paramsObject),
"manage_gameobject" => ManageGameObject.HandleCommand(paramsObject),
"manage_asset" => ManageAsset.HandleCommand(paramsObject),
"read_console" => ReadConsole.HandleCommand(paramsObject),
"execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject),
_ => throw new ArgumentException(
$"Unknown or unsupported command type: {command.type}"
),
2025-03-18 19:00:50 +08:00
};
2025-03-31 03:58:01 +08:00
// Standard success response format
2025-03-20 19:24:31 +08:00
var response = new { status = "success", result };
return JsonConvert.SerializeObject(response);
2025-03-18 19:00:50 +08:00
}
2025-03-20 19:24:31 +08:00
catch (Exception ex)
2025-03-18 19:00:50 +08:00
{
2025-03-31 03:58:01 +08:00
// Log the detailed error in Unity for debugging
Debug.LogError(
$"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}"
);
2025-03-31 03:58:01 +08:00
// Standard error response format
2025-03-20 19:24:31 +08:00
var response = new
{
status = "error",
2025-03-31 03:58:01 +08:00
error = ex.Message, // Provide the specific error message
command = command?.type ?? "Unknown", // Include the command type if available
stackTrace = ex.StackTrace, // Include stack trace for detailed debugging
paramsSummary = command?.@params != null
? GetParamsSummary(command.@params)
: "No parameters", // Summarize parameters for context
2025-03-20 19:24:31 +08:00
};
return JsonConvert.SerializeObject(response);
}
2025-03-18 19:00:50 +08:00
}
2025-03-20 19:24:31 +08:00
// Helper method to get a summary of parameters for error reporting
private static string GetParamsSummary(JObject @params)
2025-03-18 19:00:50 +08:00
{
2025-03-20 19:24:31 +08:00
try
{
2025-04-08 19:22:24 +08:00
return @params == null || !@params.HasValues
? "No parameters"
: string.Join(
", ",
@params
.Properties()
.Select(static p =>
$"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}"
)
);
2025-03-20 19:24:31 +08:00
}
catch
{
return "Could not summarize parameters";
}
2025-03-18 19:00:50 +08:00
}
}
2025-03-31 03:58:01 +08:00
}