Skip to content

Commit 6e59b8f

Browse files
committed
fix: linux XDG config paths; prefer installed server; robust cursor detection; atomic writes; uv validation; WinGet Links ordering
1 parent 6e22721 commit 6e59b8f

File tree

10 files changed

+443
-84
lines changed

10 files changed

+443
-84
lines changed

CursorHelp.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
### Cursor/VSCode/Windsurf: UV path issue on Windows (diagnosis and fix)
2+
3+
#### The issue
4+
- 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.
5+
6+
#### Typical symptoms
7+
- Cursor shows the UnityMCP server but never connects or reports it “can’t start.”
8+
- Your `%USERPROFILE%\\.cursor\\mcp.json` flips back to a different `command` path when Unity or the Unity MCP window refreshes.
9+
10+
#### Real-world example
11+
- Wrong/fragile path (auto-picked):
12+
- `C:\Users\mrken.local\bin\uv.exe` (malformed, not standard)
13+
- `C:\Users\mrken\AppData\Local\Microsoft\WinGet\Packages\astral-sh.uv_Microsoft.Winget.Source_8wekyb3d8bbwe\uv.exe`
14+
- Correct/stable path (works with Cursor):
15+
- `C:\Users\mrken\AppData\Local\Microsoft\WinGet\Links\uv.exe`
16+
17+
#### Quick fix (recommended)
18+
1) In Unity: `Window > Unity MCP` → select your MCP client (Cursor or Windsurf)
19+
2) If you see “uv Not Found,” click “Choose UV Install Location” and browse to:
20+
- `C:\Users\<YOU>\AppData\Local\Microsoft\WinGet\Links\uv.exe`
21+
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.
22+
4) Click “Auto Configure” (or re-open the client) and restart Cursor.
23+
24+
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.
25+
26+
#### Verify the fix
27+
- Confirm global Cursor config is at: `%USERPROFILE%\\.cursor\\mcp.json`
28+
- You should see something like:
29+
30+
```json
31+
{
32+
"mcpServers": {
33+
"unityMCP": {
34+
"command": "C:\\Users\\YOU\\AppData\\Local\\Microsoft\\WinGet\\Links\\uv.exe",
35+
"args": [
36+
"--directory",
37+
"C:\\Users\\YOU\\AppData\\Local\\Programs\\UnityMCP\\UnityMcpServer\\src",
38+
"run",
39+
"server.py"
40+
]
41+
}
42+
}
43+
}
44+
```
45+
46+
- Manually run the same command in PowerShell to confirm it launches:
47+
48+
```powershell
49+
"C:\Users\YOU\AppData\Local\Microsoft\WinGet\Links\uv.exe" --directory "C:\Users\YOU\AppData\Local\Programs\UnityMCP\UnityMcpServer\src" run server.py
50+
```
51+
52+
If that runs without error, restart Cursor and it should connect.
53+
54+
#### Why this happens
55+
- 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.
56+
- 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.
57+
58+
#### Extra notes
59+
- Restart Cursor after changing `mcp.json`; it doesn’t always hot-reload that file.
60+
- If you also have a project-scoped `.cursor\\mcp.json` in your Unity project folder, that file overrides the global one.
61+
62+
63+
### Why pin the WinGet Links shim (and not the Packages path)
64+
65+
- 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"`.
66+
- WinGet publishes stable launch shims in these locations:
67+
- User scope: `%LOCALAPPDATA%\Microsoft\WinGet\Links\uv.exe`
68+
- Machine scope: `C:\Program Files\WinGet\Links\uv.exe`
69+
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)
70+
- The `Packages` root is where payloads live and can change across updates, so avoid pointing your config at it.
71+
72+
Recommended practice
73+
74+
- Prefer the WinGet Links shim paths above. If present, select one via “Choose UV Install Location”.
75+
- 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.
76+
- 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.
77+
78+
References
79+
80+
- WinGet portable Links: [GitHub discussion](https://github.com/microsoft/winget-pkgs/discussions/184459)
81+
- WinGet install locations: [Super User](https://superuser.com/questions/1739292/how-to-know-where-winget-installed-a-program)
82+
- GUI client PATH caveats (Cursor): [Cursor community thread](https://forum.cursor.com/t/mcp-feature-client-closed-fix/54651?page=4)
83+
- uv tools install location (`~/.local/bin`): [Astral docs](https://docs.astral.sh/uv/concepts/tools/)
84+
85+

UnityMcpBridge/Editor/Data/McpClients.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,7 @@ public class McpClients
7171
),
7272
linuxConfigPath = Path.Combine(
7373
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
74-
"Library",
75-
"Application Support",
74+
".config",
7675
"Claude",
7776
"claude_desktop_config.json"
7877
),
@@ -91,8 +90,7 @@ public class McpClients
9190
),
9291
linuxConfigPath = Path.Combine(
9392
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
94-
"Library",
95-
"Application Support",
93+
".config",
9694
"Code",
9795
"User",
9896
"mcp.json"

UnityMcpBridge/Editor/Helpers/ServerInstaller.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ private static string GetSaveLocation()
8888
// Use Application Support for a stable, user-writable location
8989
return Path.Combine(
9090
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
91-
"UnityMCP"
91+
RootFolder
9292
);
9393
}
9494
throw new Exception("Unsupported operating system.");
@@ -126,6 +126,7 @@ private static bool TryGetEmbeddedServerSource(out string srcPath)
126126
return ServerPathResolver.TryFindEmbeddedServerSource(out srcPath);
127127
}
128128

129+
private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" };
129130
private static void CopyDirectoryRecursive(string sourceDir, string destinationDir)
130131
{
131132
Directory.CreateDirectory(destinationDir);
@@ -140,8 +141,15 @@ private static void CopyDirectoryRecursive(string sourceDir, string destinationD
140141
foreach (string dirPath in Directory.GetDirectories(sourceDir))
141142
{
142143
string dirName = Path.GetFileName(dirPath);
144+
foreach (var skip in _skipDirs)
145+
{
146+
if (dirName.Equals(skip, StringComparison.OrdinalIgnoreCase))
147+
goto NextDir;
148+
}
149+
try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { }
143150
string destSubDir = Path.Combine(destinationDir, dirName);
144151
CopyDirectoryRecursive(dirPath, destSubDir);
152+
NextDir: ;
145153
}
146154
}
147155

@@ -301,10 +309,11 @@ internal static string FindUvPath()
301309
candidates = new[]
302310
{
303311
// Preferred: WinGet Links shims (stable entrypoints)
312+
// Per-user shim, then machine-wide shim
304313
Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"),
314+
Path.Combine(programData, "Microsoft", "WinGet", "Links", "uv.exe"),
315+
// ProgramFiles Links is uncommon; keep as low-priority fallback
305316
Path.Combine(programFiles, "WinGet", "Links", "uv.exe"),
306-
// Optional low-priority fallback for atypical images
307-
Path.Combine(programData, "Microsoft", "WinGet", "Links", "uv.exe"),
308317

309318
// Common per-user installs
310319
Path.Combine(localAppData, @"Programs\Python\Python313\Scripts\uv.exe"),

UnityMcpBridge/Editor/UnityMcpBridge.cs

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -395,22 +395,80 @@ private static async Task HandleClientAsync(TcpClient client)
395395
using (client)
396396
using (NetworkStream stream = client.GetStream())
397397
{
398+
const int MaxMessageBytes = 64 * 1024 * 1024; // 64 MB safety cap
398399
byte[] buffer = new byte[8192];
399400
while (isRunning)
400401
{
401402
try
402403
{
403-
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
404-
if (bytesRead == 0)
404+
// Read message with optional length prefix (8-byte big-endian)
405+
bool usedFraming = false;
406+
string commandText = null;
407+
408+
// First, attempt to read an 8-byte header
409+
byte[] header = new byte[8];
410+
int headerFilled = 0;
411+
while (headerFilled < 8)
405412
{
406-
break; // Client disconnected
413+
int r = await stream.ReadAsync(header, headerFilled, 8 - headerFilled);
414+
if (r == 0)
415+
{
416+
// Disconnected
417+
return;
418+
}
419+
headerFilled += r;
420+
}
421+
422+
// Interpret header as big-endian payload length, with plausibility check
423+
ulong payloadLen = ReadUInt64BigEndian(header);
424+
if (payloadLen > 0 && payloadLen <= (ulong)MaxMessageBytes)
425+
{
426+
// Framed message path
427+
usedFraming = true;
428+
byte[] payload = await ReadExactAsync(stream, (int)payloadLen);
429+
commandText = System.Text.Encoding.UTF8.GetString(payload);
430+
}
431+
else
432+
{
433+
// Legacy path: treat header bytes as the beginning of a JSON/plain message and read until we have a full JSON
434+
usedFraming = false;
435+
using var ms = new MemoryStream();
436+
ms.Write(header, 0, header.Length);
437+
438+
// Read available data in chunks; stop when we have valid JSON or ping, or when no more data available for now
439+
while (true)
440+
{
441+
// If we already have enough text, try to interpret
442+
string currentText = System.Text.Encoding.UTF8.GetString(ms.ToArray());
443+
string trimmed = currentText.Trim();
444+
if (trimmed == "ping")
445+
{
446+
commandText = trimmed;
447+
break;
448+
}
449+
if (IsValidJson(trimmed))
450+
{
451+
commandText = trimmed;
452+
break;
453+
}
454+
455+
// Read next chunk
456+
int r = await stream.ReadAsync(buffer, 0, buffer.Length);
457+
if (r == 0)
458+
{
459+
// Disconnected mid-message; fall back to whatever we have
460+
commandText = currentText;
461+
break;
462+
}
463+
ms.Write(buffer, 0, r);
464+
465+
if (ms.Length > MaxMessageBytes)
466+
{
467+
throw new IOException($"Incoming message exceeded {MaxMessageBytes} bytes cap");
468+
}
469+
}
407470
}
408471

409-
string commandText = System.Text.Encoding.UTF8.GetString(
410-
buffer,
411-
0,
412-
bytesRead
413-
);
414472
string commandId = Guid.NewGuid().ToString();
415473
TaskCompletionSource<string> tcs = new();
416474

@@ -422,6 +480,14 @@ private static async Task HandleClientAsync(TcpClient client)
422480
/*lang=json,strict*/
423481
"{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}"
424482
);
483+
484+
if (usedFraming)
485+
{
486+
// Mirror framing for response
487+
byte[] outHeader = new byte[8];
488+
WriteUInt64BigEndian(outHeader, (ulong)pingResponseBytes.Length);
489+
await stream.WriteAsync(outHeader, 0, outHeader.Length);
490+
}
425491
await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length);
426492
continue;
427493
}
@@ -433,6 +499,12 @@ private static async Task HandleClientAsync(TcpClient client)
433499

434500
string response = await tcs.Task;
435501
byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
502+
if (usedFraming)
503+
{
504+
byte[] outHeader = new byte[8];
505+
WriteUInt64BigEndian(outHeader, (ulong)responseBytes.Length);
506+
await stream.WriteAsync(outHeader, 0, outHeader.Length);
507+
}
436508
await stream.WriteAsync(responseBytes, 0, responseBytes.Length);
437509
}
438510
catch (Exception ex)
@@ -444,6 +516,55 @@ private static async Task HandleClientAsync(TcpClient client)
444516
}
445517
}
446518

519+
// Read exactly count bytes or throw if stream closes prematurely
520+
private static async Task<byte[]> ReadExactAsync(NetworkStream stream, int count)
521+
{
522+
byte[] data = new byte[count];
523+
int offset = 0;
524+
while (offset < count)
525+
{
526+
int r = await stream.ReadAsync(data, offset, count - offset);
527+
if (r == 0)
528+
{
529+
throw new IOException("Connection closed before reading expected bytes");
530+
}
531+
offset += r;
532+
}
533+
return data;
534+
}
535+
536+
private static ulong ReadUInt64BigEndian(byte[] buffer)
537+
{
538+
if (buffer == null || buffer.Length < 8)
539+
{
540+
return 0UL;
541+
}
542+
return ((ulong)buffer[0] << 56)
543+
| ((ulong)buffer[1] << 48)
544+
| ((ulong)buffer[2] << 40)
545+
| ((ulong)buffer[3] << 32)
546+
| ((ulong)buffer[4] << 24)
547+
| ((ulong)buffer[5] << 16)
548+
| ((ulong)buffer[6] << 8)
549+
| buffer[7];
550+
}
551+
552+
private static void WriteUInt64BigEndian(byte[] dest, ulong value)
553+
{
554+
if (dest == null || dest.Length < 8)
555+
{
556+
throw new ArgumentException("Destination buffer too small for UInt64");
557+
}
558+
dest[0] = (byte)(value >> 56);
559+
dest[1] = (byte)(value >> 48);
560+
dest[2] = (byte)(value >> 40);
561+
dest[3] = (byte)(value >> 32);
562+
dest[4] = (byte)(value >> 24);
563+
dest[5] = (byte)(value >> 16);
564+
dest[6] = (byte)(value >> 8);
565+
dest[7] = (byte)(value);
566+
}
567+
447568
private static void ProcessCommands()
448569
{
449570
List<string> processedIds = new();

0 commit comments

Comments
 (0)