Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
398 changes: 398 additions & 0 deletions MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,398 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;

namespace MCPForUnity.Editor.Clients.Configurators
{
/// <summary>
/// Configurator for OpenClaw via the openclaw-mcp-bridge plugin.
/// OpenClaw stores config at ~/.openclaw/openclaw.json.
/// </summary>
public class OpenClawConfigurator : McpClientConfiguratorBase
{
private const string PluginName = "openclaw-mcp-bridge";
private const string ServerName = "unityMCP";
private const string HttpTransportName = "http";
private const string StdioTransportName = "stdio";
private const string StdioUrl = "stdio://local";

public OpenClawConfigurator() : base(new McpClient
{
name = "OpenClaw",
windowsConfigPath = BuildConfigPath(),
macConfigPath = BuildConfigPath(),
linuxConfigPath = BuildConfigPath()
})
{ }

private static string BuildConfigPath()
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".openclaw",
"openclaw.json");
}

public override string GetConfigPath() => CurrentOsPath();

public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
{
try
{
string path = GetConfigPath();
if (!File.Exists(path))
{
client.SetStatus(McpStatus.NotConfigured);
client.configuredTransport = ConfiguredTransport.Unknown;
return client.status;
}

JObject root = LoadConfig(path);
JObject pluginEntry = root["plugins"]?["entries"]?[PluginName] as JObject;
JObject unityServer = FindUnityServer(pluginEntry?["config"]?["servers"]);

if (pluginEntry == null || unityServer == null)
{
client.SetStatus(McpStatus.MissingConfig);
client.configuredTransport = ConfiguredTransport.Unknown;
return client.status;
}

if (!IsEnabled(pluginEntry) || !IsEnabled(unityServer))
{
client.SetStatus(McpStatus.NotConfigured);
client.configuredTransport = ConfiguredTransport.Unknown;
return client.status;
}

bool matches = ServerMatchesCurrentEndpoint(unityServer);
if (matches)
{
client.SetStatus(McpStatus.Configured);
client.configuredTransport = ResolveTransport(unityServer);
return client.status;
}

if (attemptAutoRewrite)
{
Configure();
}
else
{
client.SetStatus(McpStatus.IncorrectPath);
client.configuredTransport = ConfiguredTransport.Unknown;
}
}
catch (Exception ex)
{
client.SetStatus(McpStatus.Error, ex.Message);
client.configuredTransport = ConfiguredTransport.Unknown;
}

return client.status;
}

public override void Configure()
{
if (EditorPrefs.GetBool(EditorPrefKeys.LockCursorConfig, false))
return;

Comment on lines +102 to +106
string path = GetConfigPath();
McpConfigurationHelper.EnsureConfigDirectoryExists(path);

JObject root = File.Exists(path) ? LoadConfig(path) : new JObject();

JObject plugins = root["plugins"] as JObject ?? new JObject();
root["plugins"] = plugins;

JObject entries = plugins["entries"] as JObject ?? new JObject();
plugins["entries"] = entries;

JObject pluginEntry = entries[PluginName] as JObject ?? new JObject();
entries[PluginName] = pluginEntry;
pluginEntry["enabled"] = true;

JObject pluginConfig = pluginEntry["config"] as JObject ?? new JObject();
pluginEntry["config"] = pluginConfig;
pluginConfig.Remove("timeout"); // removed in openclaw-mcp-bridge v2+
pluginConfig.Remove("retries"); // removed in openclaw-mcp-bridge v2+
pluginConfig["servers"] = UpsertUnityServer(pluginConfig["servers"]);

McpConfigurationHelper.WriteAtomicFile(path, root.ToString(Formatting.Indented));
client.SetStatus(McpStatus.Configured);
client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
}

public override string GetManualSnippet()
{
JObject snippet = new JObject
{
["plugins"] = new JObject
{
["entries"] = new JObject
{
[PluginName] = new JObject
{
["enabled"] = true,
["config"] = new JObject
{
["servers"] = new JObject
{
[ServerName] = BuildUnityServerEntry()
}
}
}
}
}
};

return snippet.ToString(Formatting.Indented);
}

public override IList<string> GetInstallationSteps() => new List<string>
{
"Install OpenClaw",
"Install the bridge plugin: npm install -g openclaw-mcp-bridge (or pnpm add -g openclaw-mcp-bridge)",
"In MCP for Unity, choose OpenClaw and click Configure",
"OpenClaw uses the currently selected MCP for Unity transport (HTTP or stdio)",
"OpenClaw exposes a proxy tool such as unityMCP__call for Unity MCP access",
"Restart OpenClaw if the plugin does not hot-reload the new config"
};

private JObject LoadConfig(string path)
{
string text = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(text))
{
return new JObject();
}

try
{
return JsonConvert.DeserializeObject<JObject>(text) ?? new JObject();
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"OpenClaw config contains non-JSON content and cannot be safely auto-edited: {ex.Message}");
}
}

private JObject FindUnityServer(JToken serversToken)
{
if (serversToken is JObject serverMap)
{
return serverMap[ServerName] as JObject;
}

if (serversToken is JArray legacyServers)
{
foreach (JToken token in legacyServers)
{
JObject server = token as JObject;
if (server == null)
{
continue;
}

string name = server["name"]?.ToString();
if (string.Equals(name, ServerName, StringComparison.OrdinalIgnoreCase))
{
return server;
}
}
}

return null;
}

private JObject UpsertUnityServer(JToken serversToken)
{
JObject servers = NormalizeServers(serversToken);
JObject entry = servers[ServerName] as JObject ?? new JObject();
JObject desiredEntry = BuildUnityServerEntry();

entry.Remove("name");
entry.Remove("prefix");
entry.Remove("healthCheck");
entry.Remove("command");
entry.Remove("args");
entry.Remove("env");
entry.Remove("connectTimeoutMs");

foreach (var property in desiredEntry.Properties())
{
entry[property.Name] = property.Value.DeepClone();
}

servers[ServerName] = entry;

return servers;
}

private static JObject NormalizeServers(JToken serversToken)
{
if (serversToken is JObject serverMap)
{
return serverMap;
}

var normalized = new JObject();
if (!(serversToken is JArray legacyServers))
{
return normalized;
}

foreach (JToken token in legacyServers)
{
if (!(token is JObject legacyServer))
{
continue;
}

string name = legacyServer["name"]?.ToString();
if (string.IsNullOrWhiteSpace(name))
{
continue;
}

normalized[name] = legacyServer;
}

return normalized;
}

private static JObject BuildUnityServerEntry()
{
ConfiguredTransport transport = HttpEndpointUtility.GetCurrentServerTransport();
if (transport == ConfiguredTransport.Stdio)
{
var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
if (string.IsNullOrWhiteSpace(uvxPath))
{
throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings.");
}

var args = new JArray();
foreach (string value in AssetPathUtility.GetUvxDevFlagsList())
{
args.Add(value);
}
foreach (string value in AssetPathUtility.GetBetaServerFromArgsList())
{
args.Add(value);
}
args.Add(packageName);
args.Add("--transport");
args.Add("stdio");

return new JObject
{
["enabled"] = true,
["url"] = StdioUrl,
["transport"] = StdioTransportName,
["command"] = uvxPath,
["args"] = args,
["toolPrefix"] = ServerName,
["requestTimeoutMs"] = 60000,
["connectTimeoutMs"] = 15000
};
}

return new JObject
{
["enabled"] = true,
["url"] = HttpEndpointUtility.GetMcpRpcUrl(),
["transport"] = HttpTransportName,
["toolPrefix"] = ServerName,
["requestTimeoutMs"] = 30000
};
Comment on lines +309 to +316
}

private bool ServerMatchesCurrentEndpoint(JObject server)
{
if (server == null)
{
return false;
}

ConfiguredTransport expectedTransport = HttpEndpointUtility.GetCurrentServerTransport();
ConfiguredTransport configuredTransport = ResolveTransport(server);
if (configuredTransport != expectedTransport)
{
return false;
}

if (configuredTransport == ConfiguredTransport.Stdio)
{
string configuredUrl = server["url"]?.ToString();
string command = server["command"]?.ToString();
if (!UrlsEqual(configuredUrl, StdioUrl) || string.IsNullOrWhiteSpace(command))
{
return false;
}

// Validate the --from package source hasn't drifted (e.g. stable vs prerelease switch)
string[] args = (server["args"] as JArray)?.ToObject<string[]>();
string configuredSource = McpConfigurationHelper.ExtractUvxUrl(args);
string expectedSource = GetExpectedPackageSourceForValidation();
if (!string.IsNullOrEmpty(configuredSource) && !string.IsNullOrEmpty(expectedSource) &&
!McpConfigurationHelper.PathsEqual(configuredSource, expectedSource))
{
return false;
}
}
else
{
string configuredUrl = server["url"]?.ToString();
if (string.IsNullOrWhiteSpace(configuredUrl) ||
(!UrlsEqual(configuredUrl, HttpEndpointUtility.GetLocalMcpRpcUrl()) &&
!UrlsEqual(configuredUrl, HttpEndpointUtility.GetRemoteMcpRpcUrl())))
{
return false;
}
}

string toolPrefix = server["toolPrefix"]?.ToString();
return string.IsNullOrWhiteSpace(toolPrefix) ||
string.Equals(toolPrefix, ServerName, StringComparison.OrdinalIgnoreCase);
}

private static bool IsEnabled(JObject entry)
{
JToken enabledToken = entry["enabled"];
return enabledToken == null || enabledToken.Type != JTokenType.Boolean || enabledToken.Value<bool>();
}

private ConfiguredTransport ResolveTransport(JObject server)
{
string configuredTransport = server?["transport"]?.ToString();
string configuredUrl = server?["url"]?.ToString();

if (string.Equals(configuredTransport, StdioTransportName, StringComparison.OrdinalIgnoreCase) ||
UrlsEqual(configuredUrl, StdioUrl))
{
return ConfiguredTransport.Stdio;
}

if (UrlsEqual(configuredUrl, HttpEndpointUtility.GetRemoteMcpRpcUrl()))
{
return ConfiguredTransport.HttpRemote;
}

if (UrlsEqual(configuredUrl, HttpEndpointUtility.GetLocalMcpRpcUrl()))
{
return ConfiguredTransport.Http;
}

return ConfiguredTransport.Unknown;
}
}
}
Loading