Merge pull request #209 from dsarno/patch/uv-links-prefer

Windows UV stability: prefer WinGet Links, preserve pinned uv path; Claude Code unregister UI; add generic mcp_source.py
main
dsarno 2025-08-13 14:33:07 -07:00 committed by GitHub
commit a7af0cd9b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 453 additions and 57 deletions

View File

@ -36,6 +36,8 @@ Deploys your development code to the actual installation locations for testing.
3. Enter server path (or use default: `%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src`) 3. Enter server path (or use default: `%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src`)
4. Enter backup location (or use default: `%USERPROFILE%\Desktop\unity-mcp-backup`) 4. Enter backup location (or use default: `%USERPROFILE%\Desktop\unity-mcp-backup`)
**Note:** Dev deploy skips `.venv`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.git`; reduces churn and avoids copying virtualenvs.
### `restore-dev.bat` ### `restore-dev.bat`
Restores original files from backup. Restores original files from backup.
@ -73,6 +75,23 @@ Note: In recent builds, the Python server sources are also bundled inside the pa
5. **Restore** original files when done using `restore-dev.bat` 5. **Restore** original files when done using `restore-dev.bat`
## Switching MCP package sources quickly
Use `mcp_source.py` to quickly switch between different Unity MCP package sources:
**Usage:**
```bash
python mcp_source.py [--manifest /path/to/manifest.json] [--repo /path/to/unity-mcp] [--choice 1|2|3]
```
**Options:**
- **1** Upstream main (CoplayDev/unity-mcp)
- **2** Remote current branch (origin + branch)
- **3** Local workspace (file: UnityMcpBridge)
After switching, open Package Manager and Refresh to re-resolve packages.
## Troubleshooting ## Troubleshooting
### "Path not found" errors running the .bat file ### "Path not found" errors running the .bat file
@ -89,3 +108,6 @@ Note: In recent builds, the Python server sources are also bundled inside the pa
- Run `deploy-dev.bat` first to create initial backup - Run `deploy-dev.bat` first to create initial backup
- Check backup directory permissions - Check backup directory permissions
- Verify backup directory path is correct - Verify backup directory path is correct
### Windows uv path issues
- On Windows, when testing GUI clients, prefer the WinGet Links `uv.exe`; if multiple `uv.exe` exist, use "Choose UV Install Location" to pin the Links shim.

View File

@ -124,7 +124,13 @@ Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installe
1. In Unity, go to `Window > Unity MCP`. 1. In Unity, go to `Window > Unity MCP`.
2. Click `Auto-Setup`. 2. Click `Auto-Setup`.
3. Look for a green status indicator 🟢 and "Connected ✓". *(This attempts to modify the MCP Client\'s config file automatically)*. 3. Look for a green status indicator 🟢 and "Connected ✓". *(This attempts to modify the MCP Client\'s config file automatically).*
Client-specific notes
- **VSCode**: uses `Code/User/mcp.json` with top-level `servers.unityMCP` and `"type": "stdio"`. On Windows, Unity MCP writes an absolute `uv.exe` (prefers WinGet Links shim) to avoid PATH issues.
- **Cursor / Windsurf**: if `uv` is missing, the Unity MCP window shows "uv Not Found" with a quick [HELP] link and a "Choose UV Install Location" button.
- **Claude Code**: if `claude` isn't found, the window shows "Claude Not Found" with [HELP] and a "Choose Claude Location" button. Unregister now updates the UI immediately.
**Option B: Manual Configuration** **Option B: Manual Configuration**
@ -137,7 +143,23 @@ If Auto-Setup fails or you use a different client:
2. **Edit the file** to add/update the `mcpServers` section, using the *exact* paths from Step 1. 2. **Edit the file** to add/update the `mcpServers` section, using the *exact* paths from Step 1.
<details> <details>
<summary><strong>Click for OS-Specific JSON Configuration Snippets...</strong></summary> <summary><strong>Click for Client-Specific JSON Configuration Snippets...</strong></summary>
**VSCode (all OS)**
```json
{
"servers": {
"unityMCP": {
"command": "uv",
"args": ["--directory","<ABSOLUTE_PATH_TO>/UnityMcpServer/src","run","server.py"],
"type": "stdio"
}
}
}
```
On Windows, set `command` to the absolute shim, e.g. `C:\\Users\\YOU\\AppData\\Local\\Microsoft\\WinGet\\Links\\uv.exe`.
**Windows:** **Windows:**

View File

@ -53,11 +53,15 @@ namespace UnityMcpBridge.Editor.Helpers
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty;
string[] candidates = string[] candidates =
{ {
// Prefer .cmd (most reliable from non-interactive processes)
Path.Combine(appData, "npm", "claude.cmd"), Path.Combine(appData, "npm", "claude.cmd"),
Path.Combine(localAppData, "npm", "claude.cmd"), Path.Combine(localAppData, "npm", "claude.cmd"),
// Fall back to PowerShell shim if only .ps1 is present
Path.Combine(appData, "npm", "claude.ps1"),
Path.Combine(localAppData, "npm", "claude.ps1"),
}; };
foreach (string c in candidates) { if (File.Exists(c)) return c; } foreach (string c in candidates) { if (File.Exists(c)) return c; }
string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude"); string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude.ps1") ?? Where("claude");
if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; if (!string.IsNullOrEmpty(fromWhere)) return fromWhere;
#endif #endif
return null; return null;
@ -172,10 +176,16 @@ namespace UnityMcpBridge.Editor.Helpers
stderr = string.Empty; stderr = string.Empty;
try try
{ {
// Handle PowerShell scripts on Windows by invoking through powershell.exe
bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase);
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
{ {
FileName = file, FileName = isPs1 ? "powershell.exe" : file,
Arguments = args, Arguments = isPs1
? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim()
: args,
WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir, WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir,
UseShellExecute = false, UseShellExecute = false,
RedirectStandardOutput = true, RedirectStandardOutput = true,

View File

@ -2,7 +2,6 @@ using System;
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Reflection;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
@ -70,21 +69,19 @@ namespace UnityMcpBridge.Editor.Helpers
{ {
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{ {
return Path.Combine( var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local");
"AppData", return Path.Combine(localAppData, "Programs", RootFolder);
"Local",
"Programs",
RootFolder
);
} }
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{ {
return Path.Combine( var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), if (string.IsNullOrEmpty(xdg))
"bin", {
RootFolder xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty,
); ".local", "share");
}
return Path.Combine(xdg, RootFolder);
} }
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{ {
@ -270,19 +267,58 @@ namespace UnityMcpBridge.Editor.Helpers
string[] candidates; string[] candidates;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{ {
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty;
string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty;
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty;
string programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) ?? string.Empty; // optional fallback
// Fast path: resolve from PATH first
try
{
var wherePsi = new System.Diagnostics.ProcessStartInfo
{
FileName = "where",
Arguments = "uv.exe",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var wp = System.Diagnostics.Process.Start(wherePsi);
string output = wp.StandardOutput.ReadToEnd().Trim();
wp.WaitForExit(1500);
if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output))
{
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
string path = line.Trim();
if (File.Exists(path) && ValidateUvBinary(path)) return path;
}
}
}
catch { }
candidates = new[] candidates = new[]
{ {
// Preferred: WinGet Links shims (stable entrypoints)
Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"),
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 // Common per-user installs
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python313\Scripts\uv.exe"), Path.Combine(localAppData, @"Programs\Python\Python313\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python312\Scripts\uv.exe"), Path.Combine(localAppData, @"Programs\Python\Python312\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python311\Scripts\uv.exe"), Path.Combine(localAppData, @"Programs\Python\Python311\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python310\Scripts\uv.exe"), Path.Combine(localAppData, @"Programs\Python\Python310\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python313\Scripts\uv.exe"), Path.Combine(appData, @"Python\Python313\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python312\Scripts\uv.exe"), Path.Combine(appData, @"Python\Python312\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python311\Scripts\uv.exe"), Path.Combine(appData, @"Python\Python311\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python310\Scripts\uv.exe"), Path.Combine(appData, @"Python\Python310\Scripts\uv.exe"),
// Program Files style installs (if a native installer was used) // Program Files style installs (if a native installer was used)
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty, @"uv\uv.exe"), Path.Combine(programFiles, @"uv\uv.exe"),
// Try simple name resolution later via PATH // Try simple name resolution later via PATH
"uv.exe", "uv.exe",
"uv" "uv"
@ -315,33 +351,10 @@ namespace UnityMcpBridge.Editor.Helpers
catch { /* ignore */ } catch { /* ignore */ }
} }
// Use platform-appropriate which/where to resolve from PATH // Use platform-appropriate which/where to resolve from PATH (non-Windows handled here; Windows tried earlier)
try try
{ {
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var wherePsi = new System.Diagnostics.ProcessStartInfo
{
FileName = "where",
Arguments = "uv.exe",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var wp = System.Diagnostics.Process.Start(wherePsi);
string output = wp.StandardOutput.ReadToEnd().Trim();
wp.WaitForExit(3000);
if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output))
{
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
string path = line.Trim();
if (File.Exists(path) && ValidateUvBinary(path)) return path;
}
}
}
else
{ {
var whichPsi = new System.Diagnostics.ProcessStartInfo var whichPsi = new System.Diagnostics.ProcessStartInfo
{ {

View File

@ -1023,6 +1023,84 @@ namespace UnityMcpBridge.Editor.Windows
break; break;
} }
// If config already has a working absolute uv path, avoid rewriting it on refresh
try
{
if (mcpClient?.mcpType != McpTypes.ClaudeCode)
{
// Inspect existing command for stability (Windows absolute path that exists)
string existingCommand = null;
if (mcpClient?.mcpType == McpTypes.VSCode)
{
existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString();
}
else
{
existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString();
}
if (!string.IsNullOrEmpty(existingCommand))
{
bool keep = false;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Consider absolute, existing paths as stable; prefer WinGet Links
if (Path.IsPathRooted(existingCommand) && File.Exists(existingCommand))
{
keep = true;
}
}
else
{
// On Unix, keep absolute existing path as well
if (Path.IsPathRooted(existingCommand) && File.Exists(existingCommand))
{
keep = true;
}
}
if (keep)
{
// Merge without replacing the existing command
if (mcpClient?.mcpType == McpTypes.VSCode)
{
if (existingConfig.servers == null)
{
existingConfig.servers = new Newtonsoft.Json.Linq.JObject();
}
if (existingConfig.servers.unityMCP == null)
{
existingConfig.servers.unityMCP = new Newtonsoft.Json.Linq.JObject();
}
existingConfig.servers.unityMCP.args =
JsonConvert.DeserializeObject<Newtonsoft.Json.Linq.JToken>(
JsonConvert.SerializeObject(unityMCPConfig.args)
);
}
else
{
if (existingConfig.mcpServers == null)
{
existingConfig.mcpServers = new Newtonsoft.Json.Linq.JObject();
}
if (existingConfig.mcpServers.unityMCP == null)
{
existingConfig.mcpServers.unityMCP = new Newtonsoft.Json.Linq.JObject();
}
existingConfig.mcpServers.unityMCP.args =
JsonConvert.DeserializeObject<Newtonsoft.Json.Linq.JToken>(
JsonConvert.SerializeObject(unityMCPConfig.args)
);
}
string mergedKeep = JsonConvert.SerializeObject(existingConfig, jsonSettings);
File.WriteAllText(configPath, mergedKeep);
return "Configured successfully";
}
}
}
}
catch { /* fall back to normal write */ }
// Write the merged configuration back to file // Write the merged configuration back to file
string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings); string mergedJson = JsonConvert.SerializeObject(existingConfig, jsonSettings);
File.WriteAllText(configPath, mergedJson); File.WriteAllText(configPath, mergedJson);
@ -1516,13 +1594,61 @@ namespace UnityMcpBridge.Editor.Windows
string projectDir = Path.GetDirectoryName(Application.dataPath); string projectDir = Path.GetDirectoryName(Application.dataPath);
string pathPrepend = Application.platform == RuntimePlatform.OSXEditor string pathPrepend = Application.platform == RuntimePlatform.OSXEditor
? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
: "/usr/local/bin:/usr/bin:/bin"; : null; // On Windows, don't modify PATH - use system PATH as-is
if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) // Determine if Claude has a UnityMCP server registered by using exit codes from `claude mcp get <name>`
string[] candidateNamesForGet = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" };
List<string> existingNames = new List<string>();
foreach (var candidate in candidateNamesForGet)
{
if (ExecPath.TryRun(claudePath, $"mcp get {candidate}", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend))
{
// Success exit code indicates the server exists
existingNames.Add(candidate);
}
}
if (existingNames.Count == 0)
{
// Nothing to unregister set status and bail early
var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode);
if (claudeClient != null)
{
claudeClient.SetStatus(McpStatus.NotConfigured);
UnityEngine.Debug.Log("Claude CLI reports no UnityMCP server via 'mcp get' setting status to NotConfigured and aborting unregister.");
Repaint();
}
return;
}
// Try different possible server names
string[] possibleNames = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" };
bool success = false;
foreach (string serverName in possibleNames)
{
if (ExecPath.TryRun(claudePath, $"mcp remove {serverName}", projectDir, out var stdout, out var stderr, 10000, pathPrepend))
{
success = true;
UnityEngine.Debug.Log($"Successfully removed MCP server: {serverName}");
break;
}
else if (!string.IsNullOrEmpty(stderr) &&
!stderr.Contains("No MCP server found", StringComparison.OrdinalIgnoreCase))
{
// If it's not a "not found" error, log it and stop trying
UnityEngine.Debug.LogWarning($"Error removing {serverName}: {stderr}");
break;
}
}
if (success)
{ {
var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode);
if (claudeClient != null) if (claudeClient != null)
{ {
// Optimistically flip to NotConfigured; then verify
claudeClient.SetStatus(McpStatus.NotConfigured);
CheckClaudeCodeConfiguration(claudeClient); CheckClaudeCodeConfiguration(claudeClient);
} }
Repaint(); Repaint();
@ -1530,10 +1656,45 @@ namespace UnityMcpBridge.Editor.Windows
} }
else else
{ {
UnityEngine.Debug.LogWarning($"Claude MCP removal failed: {stderr}\n{stdout}"); // If no servers were found to remove, they're already unregistered
// Force status to NotConfigured and update the UI
UnityEngine.Debug.Log("No MCP servers found to unregister - already unregistered.");
var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode);
if (claudeClient != null)
{
claudeClient.SetStatus(McpStatus.NotConfigured);
CheckClaudeCodeConfiguration(claudeClient);
}
Repaint();
} }
} }
private bool ParseTextOutput(string claudePath, string projectDir, string pathPrepend)
{
if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend))
{
UnityEngine.Debug.Log($"Claude MCP servers (text): {listStdout}");
// Check if output indicates no servers or contains UnityMCP variants
if (listStdout.Contains("No MCP servers configured") ||
listStdout.Contains("no servers") ||
listStdout.Contains("No servers") ||
string.IsNullOrWhiteSpace(listStdout) ||
listStdout.Trim().Length == 0)
{
return false;
}
// Look for UnityMCP variants in the output
return listStdout.Contains("UnityMCP") ||
listStdout.Contains("unityMCP") ||
listStdout.Contains("unity-mcp");
}
// If command failed, assume no servers
return false;
}
private string FindUvPath() private string FindUvPath()
{ {
string uvPath = null; string uvPath = null;

View File

@ -1,6 +1,6 @@
{ {
"name": "com.coplaydev.unity-mcp", "name": "com.coplaydev.unity-mcp",
"version": "2.0.2", "version": "2.1.0",
"displayName": "Unity MCP Bridge", "displayName": "Unity MCP Bridge",
"description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.",
"unity": "2020.3", "unity": "2020.3",

168
mcp_source.py Executable file
View File

@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Generic helper to switch the Unity MCP package source in a Unity project's
Packages/manifest.json. This is useful for switching between upstream and local repos while working on the MCP.
Usage:
python mcp_source.py [--manifest /abs/path/to/manifest.json] [--repo /abs/path/to/unity-mcp] [--choice 1|2|3]
Choices:
1) Upstream main (CoplayDev/unity-mcp)
2) Your remote current branch (derived from `origin` and current branch)
3) Local repo workspace (file: URL to UnityMcpBridge in your checkout)
"""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import re
import subprocess
import sys
from typing import Optional
PKG_NAME = "com.coplaydev.unity-mcp"
BRIDGE_SUBPATH = "UnityMcpBridge"
def run_git(repo: pathlib.Path, *args: str) -> str:
result = subprocess.run([
"git", "-C", str(repo), *args
], capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed")
return result.stdout.strip()
def normalize_origin_to_https(url: str) -> str:
"""Map common SSH origin forms to https for Unity's git URL scheme."""
if url.startswith("git@github.com:"):
owner_repo = url.split(":", 1)[1]
if owner_repo.endswith(".git"):
owner_repo = owner_repo[:-4]
return f"https://github.com/{owner_repo}.git"
# already https or file: etc.
return url
def detect_repo_root(explicit: Optional[str]) -> pathlib.Path:
if explicit:
return pathlib.Path(explicit).resolve()
# Prefer the git toplevel from the script's directory
here = pathlib.Path(__file__).resolve().parent
try:
top = run_git(here, "rev-parse", "--show-toplevel")
return pathlib.Path(top)
except Exception:
return here
def detect_branch(repo: pathlib.Path) -> str:
return run_git(repo, "rev-parse", "--abbrev-ref", "HEAD")
def detect_origin(repo: pathlib.Path) -> str:
url = run_git(repo, "remote", "get-url", "origin")
return normalize_origin_to_https(url)
def find_manifest(explicit: Optional[str]) -> pathlib.Path:
if explicit:
return pathlib.Path(explicit).resolve()
# Walk up from CWD looking for Packages/manifest.json
cur = pathlib.Path.cwd().resolve()
for parent in [cur, *cur.parents]:
candidate = parent / "Packages" / "manifest.json"
if candidate.exists():
return candidate
raise FileNotFoundError("Could not find Packages/manifest.json from current directory. Use --manifest to specify a path.")
def read_json(path: pathlib.Path) -> dict:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def write_json(path: pathlib.Path, data: dict) -> None:
with path.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
def build_options(repo_root: pathlib.Path, branch: str, origin_https: str):
upstream = "git+https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge"
# Ensure origin is https
origin = origin_https
# If origin is a local file path or non-https, try to coerce to https github if possible
if origin.startswith("file:"):
# Not meaningful for remote option; keep upstream
origin_remote = upstream
else:
origin_remote = origin
return [
("[1] Upstream main", upstream),
("[2] Remote current branch", f"{origin_remote}?path=/{BRIDGE_SUBPATH}#{branch}"),
("[3] Local workspace", f"file:{(repo_root / BRIDGE_SUBPATH).as_posix()}"),
]
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Switch Unity MCP package source")
p.add_argument("--manifest", help="Path to Packages/manifest.json")
p.add_argument("--repo", help="Path to unity-mcp repo root (for local file option)")
p.add_argument("--choice", choices=["1", "2", "3"], help="Pick option non-interactively")
return p.parse_args()
def main() -> None:
args = parse_args()
try:
repo_root = detect_repo_root(args.repo)
branch = detect_branch(repo_root)
origin = detect_origin(repo_root)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
options = build_options(repo_root, branch, origin)
try:
manifest_path = find_manifest(args.manifest)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print("Select MCP package source by number:")
for label, _ in options:
print(label)
if args.choice:
choice = args.choice
else:
choice = input("Enter 1-3: ").strip()
if choice not in {"1", "2", "3"}:
print("Invalid selection.", file=sys.stderr)
sys.exit(1)
idx = int(choice) - 1
_, chosen = options[idx]
data = read_json(manifest_path)
deps = data.get("dependencies", {})
if PKG_NAME not in deps:
print(f"Error: '{PKG_NAME}' not found in manifest dependencies.", file=sys.stderr)
sys.exit(1)
print(f"\nUpdating {PKG_NAME}{chosen}")
deps[PKG_NAME] = chosen
data["dependencies"] = deps
write_json(manifest_path, data)
print(f"Done. Wrote to: {manifest_path}")
print("Tip: In Unity, open Package Manager and Refresh to re-resolve packages.")
if __name__ == "__main__":
main()