Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
85 changes: 85 additions & 0 deletions CursorHelp.md
Original file line number Diff line number Diff line change
@@ -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\<YOU>\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/)


6 changes: 2 additions & 4 deletions UnityMcpBridge/Editor/Data/McpClients.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,7 @@ public class McpClients
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Application Support",
".config",
"Claude",
"claude_desktop_config.json"
),
Expand All @@ -91,8 +90,7 @@ public class McpClients
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Application Support",
".config",
"Code",
"User",
"mcp.json"
Expand Down
15 changes: 12 additions & 3 deletions UnityMcpBridge/Editor/Helpers/ServerInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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);
Expand All @@ -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: ;
}
}

Expand Down Expand Up @@ -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"),
Expand Down
137 changes: 129 additions & 8 deletions UnityMcpBridge/Editor/UnityMcpBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> tcs = new();

Expand All @@ -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;
}
Expand All @@ -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)
Expand All @@ -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<byte[]> 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<string> processedIds = new();
Expand Down
Loading