Skip to content
Open
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
27 changes: 27 additions & 0 deletions MCPForUnity/Editor/Clients/Configurators/WorkBuddyConfigurator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;

namespace MCPForUnity.Editor.Clients.Configurators
{
public class WorkBuddyConfigurator : JsonFileMcpConfigurator
{
public WorkBuddyConfigurator() : base(new McpClient
{
name = "WorkBuddy",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".workbuddy", "mcp.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".workbuddy", "mcp.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".workbuddy", "mcp.json"),
})
{ }

public override IList<string> GetInstallationSteps() => new List<string>
{
"Install and launch WorkBuddy to create its '~/.workbuddy' directory",
"Click Configure to add UnityMCP to '~/.workbuddy/mcp.json'",
"In WorkBuddy, open Plugins > MCP Servers and enable UnityMCP",
"Restart WorkBuddy or reconnect the server if needed"
};
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 7 additions & 9 deletions MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public static string WriteMcpConfiguration(string configPath, McpClient mcpClien
catch (Exception e)
{
McpLog.Warn($"Error reading existing config: {e.Message}.");
return "Could not read the existing configuration file. The file was left unchanged.";
}
}

Expand All @@ -66,14 +67,10 @@ public static string WriteMcpConfiguration(string configPath, McpClient mcpClien
existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new JObject();
}
}
catch
catch (JsonException)
{
// If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object
if (!string.IsNullOrWhiteSpace(existingJson))
{
McpLog.Warn("UnityMCP: Configuration file could not be parsed; rewriting server block.");
}
existingConfig = new JObject();
// A partial edit must not discard unrelated settings or server entries.
return "Could not parse the existing configuration file. Fix its JSON and try again. The file was left unchanged.";
}

// Determine existing entry references (command/args)
Expand Down Expand Up @@ -230,8 +227,9 @@ public static bool PathsEqual(string a, string b)

public static void WriteAtomicFile(string path, string contents)
{
string tmp = path + ".tmp";
string backup = path + ".backup";
string operationId = Guid.NewGuid().ToString("N");
string tmp = path + "." + operationId + ".tmp";
string backup = path + "." + operationId + ".backup";
bool writeDone = false;
try
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MCPForUnity.Editor.Clients;
using MCPForUnity.Editor.Clients.Configurators;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;

namespace MCPForUnityTests.Editor.Clients
{
[TestFixture]
public class WorkBuddyConfiguratorTests : TransportPreferenceTestBase
{
private string _tempDir;
private readonly Dictionary<string, string> _savedPrefs = new();
private bool _hadLock;
private bool _locked;

[SetUp]
public void SetUp()
{
foreach (string key in new[] { EditorPrefKeys.HttpTransportScope, EditorPrefKeys.HttpBaseUrl })
_savedPrefs[key] = EditorPrefs.HasKey(key) ? EditorPrefs.GetString(key) : null;
_hadLock = EditorPrefs.HasKey(EditorPrefKeys.LockCursorConfig);
_locked = EditorPrefs.GetBool(EditorPrefKeys.LockCursorConfig, false);
EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, "local");
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorPrefs.SetBool(EditorPrefKeys.LockCursorConfig, false);
EditorConfigurationCache.Instance.Refresh();
_tempDir = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}

[TearDown]
public void TearDown()
{
foreach (var pref in _savedPrefs)
{
if (pref.Value == null) EditorPrefs.DeleteKey(pref.Key);
else EditorPrefs.SetString(pref.Key, pref.Value);
}
_savedPrefs.Clear();
if (_hadLock) EditorPrefs.SetBool(EditorPrefKeys.LockCursorConfig, _locked);
else EditorPrefs.DeleteKey(EditorPrefKeys.LockCursorConfig);
EditorConfigurationCache.Instance.Refresh();
if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true);
}

[Test]
public void Registry_DiscoversWorkBuddyAsASeparateClient()
{
var workBuddy = McpClientRegistry.All.Single(c => c.Id == "workbuddy");
Assert.IsInstanceOf<WorkBuddyConfigurator>(workBuddy);
Assert.AreEqual("WorkBuddy", workBuddy.DisplayName);
Assert.AreNotEqual(new CodeBuddyCliConfigurator().GetConfigPath(), workBuddy.GetConfigPath());
}

[Test]
public void ConfigPath_UsesWorkBuddyUserConfigOnEveryPlatform()
{
var configurator = new WorkBuddyConfigurator();
string expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".workbuddy", "mcp.json");
Assert.AreEqual(expected, configurator.Client.windowsConfigPath);
Assert.AreEqual(expected, configurator.Client.macConfigPath);
Assert.AreEqual(expected, configurator.Client.linuxConfigPath);
}

[TestCase(true)]
[TestCase(false)]
public void Configure_RoundTripsTransportAndPreservesOtherSettings(bool http)
{
EditorConfigurationCache.Instance.SetUseHttpTransport(http);
var configurator = new TempWorkBuddyConfigurator(Path.Combine(_tempDir, "mcp.json"));
File.WriteAllText(configurator.GetConfigPath(), "{\"theme\":\"dark\",\"mcpServers\":{\"other\":{\"command\":\"other-server\"}}}");

configurator.Configure();
string firstWrite = File.ReadAllText(configurator.GetConfigPath());
configurator.Configure();
Assert.AreEqual(firstWrite, File.ReadAllText(configurator.GetConfigPath()), "Repeated configuration must be idempotent");
Assert.AreEqual(McpStatus.Configured, configurator.CheckStatus(attemptAutoRewrite: false));
Assert.AreEqual(http ? ConfiguredTransport.Http : ConfiguredTransport.Stdio, configurator.ConfiguredTransport);

var root = JObject.Parse(firstWrite);
Assert.AreEqual("dark", (string)root["theme"]);
Assert.AreEqual("other-server", (string)root["mcpServers"]?["other"]?["command"]);
var unity = (JObject)root["mcpServers"]?["unityMCP"];
Assert.IsNotNull(unity);
Assert.AreEqual(http ? "http" : "stdio", (string)unity["type"]);
if (http)
{
Assert.AreEqual("http://127.0.0.1:8080/mcp", (string)unity["url"]);
Assert.IsNull(unity["command"]);
}
else
{
Assert.IsNotEmpty((string)unity["command"]);
CollectionAssert.Contains(unity["args"].ToObject<string[]>(), "stdio");
Assert.IsNull(unity["url"]);
}

configurator.Unregister();
root = JObject.Parse(File.ReadAllText(configurator.GetConfigPath()));
Assert.IsNull(root["mcpServers"]?["unityMCP"]);
Assert.AreEqual("other-server", (string)root["mcpServers"]?["other"]?["command"]);
Assert.AreEqual("dark", (string)root["theme"]);
Assert.AreEqual(McpStatus.NotConfigured, configurator.Status);
}

[TestCase(true)]
[TestCase(false)]
public void Configure_InvalidJsonLeavesExistingFileUnchanged(bool http)
{
EditorConfigurationCache.Instance.SetUseHttpTransport(http);
var configurator = new TempWorkBuddyConfigurator(Path.Combine(_tempDir, "mcp.json"));
const string incompleteJson = "{\"theme\":\"dark\",\"mcpServers\":{\"other\":{\"command\":\"other-server\"}}";
File.WriteAllText(configurator.GetConfigPath(), incompleteJson);
byte[] original = File.ReadAllBytes(configurator.GetConfigPath());

var error = Assert.Throws<InvalidOperationException>(() => configurator.Configure());

StringAssert.Contains("Fix its JSON", error.Message);
CollectionAssert.AreEqual(original, File.ReadAllBytes(configurator.GetConfigPath()));
Assert.IsFalse(File.Exists(configurator.GetConfigPath() + ".tmp"));
Assert.IsFalse(File.Exists(configurator.GetConfigPath() + ".backup"));
}

[Test]
public void Configure_UnreadableExistingFileIsLeftUnchanged()
{
var configurator = new TempWorkBuddyConfigurator(Path.Combine(_tempDir, "mcp.json"));
const string existingJson = "{\"theme\":\"dark\",\"mcpServers\":{\"other\":{\"command\":\"other-server\"}}}";
File.WriteAllText(configurator.GetConfigPath(), existingJson);
byte[] original = File.ReadAllBytes(configurator.GetConfigPath());

using (new FileStream(configurator.GetConfigPath(), FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
var error = Assert.Throws<InvalidOperationException>(() => configurator.Configure());
StringAssert.Contains("Could not read", error.Message);
}

CollectionAssert.AreEqual(original, File.ReadAllBytes(configurator.GetConfigPath()));
}

[TestCase(true)]
[TestCase(false)]
public void Configure_PreservesExistingTemporaryAndBackupArtifacts(bool temporaryIsDirectory)
{
var configurator = new TempWorkBuddyConfigurator(Path.Combine(_tempDir, "mcp.json"));
string path = configurator.GetConfigPath();
File.WriteAllText(path, "{\"theme\":\"current\",\"mcpServers\":{\"other\":{\"command\":\"other-server\"}}}");
const string temporaryContents = "user temporary contents";
if (temporaryIsDirectory) Directory.CreateDirectory(path + ".tmp");
else File.WriteAllText(path + ".tmp", temporaryContents);
File.WriteAllText(path + ".backup", "{\"theme\":\"stale backup\"}");
byte[] originalBackup = File.ReadAllBytes(path + ".backup");
byte[] originalTemporary = temporaryIsDirectory ? null : File.ReadAllBytes(path + ".tmp");
string[] originalEntries = Directory.GetFileSystemEntries(_tempDir);

configurator.Configure();

var root = JObject.Parse(File.ReadAllText(path));
Assert.AreEqual("current", (string)root["theme"]);
Assert.AreEqual("other-server", (string)root["mcpServers"]["other"]["command"]);
Assert.IsNotNull(root["mcpServers"]["unityMCP"]);
CollectionAssert.AreEqual(originalBackup, File.ReadAllBytes(path + ".backup"));
if (temporaryIsDirectory) Assert.IsTrue(Directory.Exists(path + ".tmp"));
else CollectionAssert.AreEqual(originalTemporary, File.ReadAllBytes(path + ".tmp"));
CollectionAssert.AreEquivalent(originalEntries, Directory.GetFileSystemEntries(_tempDir));
}

[Test]
public void AtomicWrite_FailurePreservesExistingBackupAndCleansOwnTemporaryFiles()
{
string path = Path.Combine(_tempDir, "destination-directory");
Directory.CreateDirectory(path);
string child = Path.Combine(path, "keep.txt");
File.WriteAllText(child, "keep directory contents");
File.WriteAllText(path + ".backup", "user backup contents");
byte[] originalBackup = File.ReadAllBytes(path + ".backup");
string[] originalEntries = Directory.GetFileSystemEntries(_tempDir);

Assert.Throws<Exception>(() => McpConfigurationHelper.WriteAtomicFile(path, "replacement"));

Assert.AreEqual("keep directory contents", File.ReadAllText(child));
CollectionAssert.AreEqual(originalBackup, File.ReadAllBytes(path + ".backup"));
CollectionAssert.AreEquivalent(originalEntries, Directory.GetFileSystemEntries(_tempDir));
}

[Test]
public void IsInstalled_RequiresTheWorkBuddyDirectory()
{
var configurator = new TempWorkBuddyConfigurator(Path.Combine(_tempDir, ".workbuddy", "mcp.json"));
Assert.IsFalse(configurator.IsInstalled);
Directory.CreateDirectory(Path.GetDirectoryName(configurator.GetConfigPath()));
Assert.IsTrue(configurator.IsInstalled);
Assert.IsFalse(File.Exists(configurator.GetConfigPath()));
}

private sealed class TempWorkBuddyConfigurator : WorkBuddyConfigurator
{
private readonly string _path;
public TempWorkBuddyConfigurator(string path) => _path = path;
public override string GetConfigPath() => _path;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions website/docs/getting-started/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ MCP for Unity auto-configures every client the package detects on your machine.
| **Antigravity** | HTTP | yes | yes | varies | Requires an MCP toggle in Antigravity settings. |
| **Pi** | stdio / HTTP | yes | yes | yes | Needs an MCP extension (Pi ships no MCP client of its own): `pi install npm:pi-mcp-adapter`. Writes the shared `~/.config/mcp/mcp.json`. |

## WorkBuddy

Select **WorkBuddy** in the Unity MCP window and click **Configure**. The configurator
writes the user-level `~/.workbuddy/mcp.json` on Windows, macOS, and Linux, using the
selected HTTP or stdio transport. Existing settings and other MCP servers are preserved.

In WorkBuddy, open **Plugins → MCP Servers** and enable `unityMCP`, then reconnect or
restart WorkBuddy if needed. WorkBuddy uses its own configuration file, separate from
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
CodeBuddy CLI. See the [official WorkBuddy MCP guide](https://www.codebuddy.cn/docs/workbuddy/From-Beginner-to-Expert-Guide/Function-Description/MCP-Guide).

## How to pick

- **You want it to just work**: Claude Desktop. Stdio means no port conflicts and no firewall prompts.
Expand All @@ -48,6 +58,7 @@ A few clients need an MCP toggle flipped on after the configurator writes their
- **OpenClaw** → enable the `openclaw-mcp-bridge` plugin
- **Pi** → install an MCP extension first (`pi install npm:pi-mcp-adapter`) and restart Pi; Pi has no
built-in MCP client, so the config file alone does nothing
- **WorkBuddy** → Plugins → MCP Servers → enable the `unityMCP` server, then reconnect or restart WorkBuddy if needed

Everything else just connects on next launch.

Expand Down
Loading