added remote install of python server

main
Justin Barnett 2025-04-08 07:22:24 -04:00
parent 75b62d3710
commit 61a7cb9e28
3 changed files with 179 additions and 23 deletions

View File

@ -0,0 +1,124 @@
using System;
using System.Net.Http;
using System.Text.RegularExpressions;
using UnityEngine;
namespace UnityMcpBridge.Editor.Helpers
{
public static class ServerInstaller
{
private const string PackageName = "unity-mcp-server";
private const string GitUrlTemplate =
"git+https://github.com/justinpbarnett/unity-mcp.git@{0}#subdirectory=UnityMcpServer";
private const string PyprojectUrlTemplate =
"https://raw.githubusercontent.com/justinpbarnett/unity-mcp/{0}/UnityMcpServer/pyproject.toml";
private const string DefaultBranch = "master";
/// <summary>
/// Ensures that UnityMcpServer is installed and up to date by checking the typical application path via Python's package manager.
/// </summary>
/// <param name="branch">The GitHub branch to install from. Defaults to "master" if not specified.</param>
public static void EnsureServerInstalled(string branch = DefaultBranch)
{
try
{
// Format the URLs with the specified branch
string gitUrl = string.Format(GitUrlTemplate, branch);
// Check if unity-mcp-server is installed using uv
string output = RunCommand("uv", $"pip show {PackageName}");
if (output.Contains("WARNING: Package(s) not found"))
{
Debug.Log($"Installing {PackageName} from branch '{branch}'...");
RunCommand("uv", $"pip install {gitUrl}");
Debug.Log($"{PackageName} installed successfully.");
}
else
{
// Extract the installed version
string installedVersion = GetVersionFromPipShow(output);
// Get the latest version from GitHub
string latestVersion = GetLatestVersionFromGitHub(branch);
// Compare versions
if (new Version(installedVersion) < new Version(latestVersion))
{
Debug.Log(
$"Updating {PackageName} from {installedVersion} to {latestVersion} (branch '{branch}')..."
);
RunCommand("uv", $"pip install --upgrade {gitUrl}");
Debug.Log($"{PackageName} updated successfully.");
}
else
{
Debug.Log($"{PackageName} is up to date (version {installedVersion}).");
}
}
}
catch (Exception ex)
{
Debug.LogError($"Failed to ensure {PackageName} is installed: {ex.Message}");
Debug.LogWarning(
"Please ensure 'uv' is installed and accessible. See the Unity MCP README for installation instructions."
);
}
}
/// <summary>
/// Executes a command and returns its output.
/// </summary>
private static string RunCommand(string fileName, string arguments)
{
System.Diagnostics.Process process = new();
process.StartInfo.FileName = fileName;
process.StartInfo.Arguments = arguments;
process.StartInfo.UseShellExecute = false;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.Start();
string output = process.StandardOutput.ReadToEnd();
string error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
throw new Exception(
$"Command '{fileName} {arguments}' failed with exit code {process.ExitCode}: {error}"
);
}
return output;
}
/// <summary>
/// Extracts the version from 'uv pip show' output.
/// </summary>
private static string GetVersionFromPipShow(string output)
{
string[] lines = output.Split('\n');
foreach (string line in lines)
{
if (line.StartsWith("Version:"))
{
return line["Version:".Length..].Trim();
}
}
throw new Exception("Version not found in pip show output");
}
/// <summary>
/// Fetches the latest version from the GitHub repository's pyproject.toml.
/// </summary>
/// <param name="branch">The GitHub branch to fetch the version from.</param>
private static string GetLatestVersionFromGitHub(string branch)
{
string pyprojectUrl = string.Format(PyprojectUrlTemplate, branch);
using HttpClient client = new();
// Add GitHub headers to avoid rate limiting
client.DefaultRequestHeaders.Add("User-Agent", "UnityMcpBridge");
string content = client.GetStringAsync(pyprojectUrl).Result;
string pattern = @"version\s*=\s*""(.*?)""";
Match match = Regex.Match(content, pattern);
return match.Success
? match.Groups[1].Value
: throw new Exception("Could not find version in pyproject.toml");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5862c6a6d0a914f4d83224f8d039cf7b

View File

@ -9,6 +9,7 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
using UnityMcpBridge.Editor.Helpers;
using UnityMcpBridge.Editor.Models; using UnityMcpBridge.Editor.Models;
using UnityMcpBridge.Editor.Tools; using UnityMcpBridge.Editor.Tools;
@ -31,14 +32,18 @@ namespace UnityMcpBridge.Editor
public static bool FolderExists(string path) public static bool FolderExists(string path)
{ {
if (string.IsNullOrEmpty(path)) if (string.IsNullOrEmpty(path))
{
return false; return false;
}
if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase)) if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase))
{
return true; return true;
}
string fullPath = Path.Combine( string fullPath = Path.Combine(
Application.dataPath, Application.dataPath,
path.StartsWith("Assets/") ? path.Substring(7) : path path.StartsWith("Assets/") ? path[7..] : path
); );
return Directory.Exists(fullPath); return Directory.Exists(fullPath);
} }
@ -51,12 +56,23 @@ namespace UnityMcpBridge.Editor
public static void Start() public static void Start()
{ {
try
{
ServerInstaller.EnsureServerInstalled();
}
catch (Exception ex)
{
Debug.LogError($"Failed to ensure UnityMcpServer is installed: {ex.Message}");
}
if (isRunning) if (isRunning)
{
return; return;
}
isRunning = true; isRunning = true;
listener = new TcpListener(IPAddress.Loopback, unityPort); listener = new TcpListener(IPAddress.Loopback, unityPort);
listener.Start(); listener.Start();
Debug.Log($"UnityMCPBridge started on port {unityPort}.");
Task.Run(ListenerLoop); Task.Run(ListenerLoop);
EditorApplication.update += ProcessCommands; EditorApplication.update += ProcessCommands;
} }
@ -64,7 +80,10 @@ namespace UnityMcpBridge.Editor
public static void Stop() public static void Stop()
{ {
if (!isRunning) if (!isRunning)
{
return; return;
}
isRunning = false; isRunning = false;
listener.Stop(); listener.Stop();
EditorApplication.update -= ProcessCommands; EditorApplication.update -= ProcessCommands;
@ -77,7 +96,7 @@ namespace UnityMcpBridge.Editor
{ {
try try
{ {
var client = await listener.AcceptTcpClientAsync(); TcpClient client = await listener.AcceptTcpClientAsync();
// Enable basic socket keepalive // Enable basic socket keepalive
client.Client.SetSocketOption( client.Client.SetSocketOption(
SocketOptionLevel.Socket, SocketOptionLevel.Socket,
@ -94,7 +113,9 @@ namespace UnityMcpBridge.Editor
catch (Exception ex) catch (Exception ex)
{ {
if (isRunning) if (isRunning)
{
Debug.LogError($"Listener error: {ex.Message}"); Debug.LogError($"Listener error: {ex.Message}");
}
} }
} }
} }
@ -102,16 +123,18 @@ namespace UnityMcpBridge.Editor
private static async Task HandleClientAsync(TcpClient client) private static async Task HandleClientAsync(TcpClient client)
{ {
using (client) using (client)
using (var stream = client.GetStream()) using (NetworkStream stream = client.GetStream())
{ {
var buffer = new byte[8192]; byte[] buffer = new byte[8192];
while (isRunning) while (isRunning)
{ {
try try
{ {
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0) if (bytesRead == 0)
{
break; // Client disconnected break; // Client disconnected
}
string commandText = System.Text.Encoding.UTF8.GetString( string commandText = System.Text.Encoding.UTF8.GetString(
buffer, buffer,
@ -119,13 +142,14 @@ namespace UnityMcpBridge.Editor
bytesRead bytesRead
); );
string commandId = Guid.NewGuid().ToString(); string commandId = Guid.NewGuid().ToString();
var tcs = new TaskCompletionSource<string>(); TaskCompletionSource<string> tcs = new();
// Special handling for ping command to avoid JSON parsing // Special handling for ping command to avoid JSON parsing
if (commandText.Trim() == "ping") if (commandText.Trim() == "ping")
{ {
// Direct response to ping without going through JSON parsing // Direct response to ping without going through JSON parsing
byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes(
/*lang=json,strict*/
"{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}"
); );
await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length); await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length);
@ -155,11 +179,16 @@ namespace UnityMcpBridge.Editor
List<string> processedIds = new(); List<string> processedIds = new();
lock (lockObj) lock (lockObj)
{ {
foreach (var kvp in commandQueue.ToList()) foreach (
KeyValuePair<
string,
(string commandJson, TaskCompletionSource<string> tcs)
> kvp in commandQueue.ToList()
)
{ {
string id = kvp.Key; string id = kvp.Key;
string commandText = kvp.Value.commandJson; string commandText = kvp.Value.commandJson;
var tcs = kvp.Value.tcs; TaskCompletionSource<string> tcs = kvp.Value.tcs;
try try
{ {
@ -200,7 +229,7 @@ namespace UnityMcpBridge.Editor
status = "error", status = "error",
error = "Invalid JSON format", error = "Invalid JSON format",
receivedText = commandText.Length > 50 receivedText = commandText.Length > 50
? commandText.Substring(0, 50) + "..." ? commandText[..50] + "..."
: commandText, : commandText,
}; };
tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse));
@ -209,7 +238,7 @@ namespace UnityMcpBridge.Editor
} }
// Normal JSON command processing // Normal JSON command processing
var command = JsonConvert.DeserializeObject<Command>(commandText); Command command = JsonConvert.DeserializeObject<Command>(commandText);
if (command == null) if (command == null)
{ {
var nullCommandResponse = new var nullCommandResponse = new
@ -236,7 +265,7 @@ namespace UnityMcpBridge.Editor
error = ex.Message, error = ex.Message,
commandType = "Unknown (error during processing)", commandType = "Unknown (error during processing)",
receivedText = commandText?.Length > 50 receivedText = commandText?.Length > 50
? commandText.Substring(0, 50) + "..." ? commandText[..50] + "..."
: commandText, : commandText,
}; };
string responseJson = JsonConvert.SerializeObject(response); string responseJson = JsonConvert.SerializeObject(response);
@ -246,7 +275,7 @@ namespace UnityMcpBridge.Editor
processedIds.Add(id); processedIds.Add(id);
} }
foreach (var id in processedIds) foreach (string id in processedIds)
{ {
commandQueue.Remove(id); commandQueue.Remove(id);
} }
@ -257,7 +286,9 @@ namespace UnityMcpBridge.Editor
private static bool IsValidJson(string text) private static bool IsValidJson(string text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{
return false; return false;
}
text = text.Trim(); text = text.Trim();
if ( if (
@ -357,17 +388,16 @@ namespace UnityMcpBridge.Editor
{ {
try try
{ {
if (@params == null || !@params.HasValues) return @params == null || !@params.HasValues
return "No parameters"; ? "No parameters"
: string.Join(
return string.Join( ", ",
", ", @params
@params .Properties()
.Properties() .Select(static p =>
.Select(p => $"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}"
$"{p.Name}: {p.Value?.ToString()?.Substring(0, Math.Min(20, p.Value?.ToString()?.Length ?? 0))}" )
) );
);
} }
catch catch
{ {