Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;

namespace MCPForUnity.Editor.Clients.Configurators
{
public class VSCodeInsidersConfigurator : JsonFileMcpConfigurator
{
public VSCodeInsidersConfigurator() : base(new McpClient
{
name = "VSCode Insiders GitHub Copilot",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code - Insiders", "User", "mcp.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code - Insiders", "User", "mcp.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code - Insiders", "User", "mcp.json"),
IsVsCodeLayout = true
})
{ }

public override IList<string> GetInstallationSteps() => new List<string>
{
"Install GitHub Copilot extension in VS Code Insiders",
"Open or create mcp.json at the path above",
"Paste the configuration JSON",
"Save and restart VS Code Insiders"
};
}
}

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

20 changes: 18 additions & 2 deletions MCPForUnity/Editor/Services/ServerManagementService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public bool ClearUvxCache()
try
{
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
string uvCommand = uvxPath.Remove(uvxPath.Length - 1, 1);
string uvCommand = BuildUvPathFromUvx(uvxPath);

// Get the package name
string packageName = "mcp-for-unity";
Expand Down Expand Up @@ -73,7 +73,7 @@ private bool ExecuteUvCommand(string uvCommand, string args, out string stdout,
stderr = null;

string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
string uvPath = uvxPath.Remove(uvxPath.Length - 1, 1);
string uvPath = BuildUvPathFromUvx(uvxPath);

if (!string.Equals(uvCommand, uvPath, StringComparison.OrdinalIgnoreCase))
{
Expand All @@ -99,6 +99,22 @@ private bool ExecuteUvCommand(string uvCommand, string args, out string stdout,
return ExecPath.TryRun(uvPath, args, Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend);
}

private static string BuildUvPathFromUvx(string uvxPath)
{
if (string.IsNullOrWhiteSpace(uvxPath))
{
return uvxPath;
}

string directory = Path.GetDirectoryName(uvxPath);
string extension = Path.GetExtension(uvxPath);
string uvFileName = "uv" + extension;

return string.IsNullOrEmpty(directory)
? uvFileName
: Path.Combine(directory, uvFileName);
}

private string GetPlatformSpecificPathPrepend()
{
if (Application.platform == RuntimePlatform.OSXEditor)
Expand Down
34 changes: 29 additions & 5 deletions Server/src/core/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,33 @@
HAS_HTTPX = False

logger = logging.getLogger("unity-mcp-telemetry")
PACKAGE_NAME = "MCPForUnityServer"


def _version_from_local_pyproject() -> str:
"""Locate the nearest pyproject.toml that matches our package name."""
current = Path(__file__).resolve()
for parent in current.parents:
candidate = parent / "pyproject.toml"
if not candidate.exists():
continue
try:
with candidate.open("rb") as f:
data = tomli.load(f)
except (OSError, tomli.TOMLDecodeError):
continue

project_table = data.get("project") or {}
poetry_table = data.get("tool", {}).get("poetry", {})

project_name = project_table.get("name") or poetry_table.get("name")
if project_name and project_name.lower() != PACKAGE_NAME.lower():
continue

version = project_table.get("version") or poetry_table.get("version")
if version:
return version
raise FileNotFoundError("pyproject.toml not found for MCPForUnityServer")


def get_package_version() -> str:
Expand All @@ -44,14 +71,11 @@ def get_package_version() -> str:
Default is "unknown", but that should never happen
"""
try:
return metadata.version("MCPForUnityServer")
return metadata.version(PACKAGE_NAME)
except Exception:
# Fallback for development: read from pyproject.toml
try:
pyproject_path = Path(__file__).parent / "pyproject.toml"
with open(pyproject_path, "rb") as f:
data = tomli.load(f)
return data["project"]["version"]
return _version_from_local_pyproject()
except Exception:
return "unknown"

Expand Down
17 changes: 15 additions & 2 deletions Server/src/services/custom_tool_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ def _register_tool(self, project_id: str, definition: ToolDefinitionModel) -> No
self._project_tools.setdefault(project_id, {})[
definition.name] = definition

def get_project_id_for_hash(self, project_hash: str | None) -> str | None:
if not project_hash:
return None
return self._hash_to_project.get(project_hash.lower())

async def _poll_until_complete(
self,
tool_name: str,
Expand Down Expand Up @@ -317,8 +322,16 @@ def resolve_project_id_for_unity_instance(unity_instance: str | None) -> str | N
hash_part = unity_instance

if hash_part:
# Return the hash directly as the identifier for WebSocket tools
return hash_part.lower()
lowered = hash_part.lower()
mapped: Optional[str] = None
try:
service = CustomToolService.get_instance()
mapped = service.get_project_id_for_hash(lowered)
except RuntimeError:
mapped = None
if mapped:
return mapped
return lowered
except Exception:
logger.debug(
f"Failed to resolve project id via plugin hub for {unity_instance}")
Expand Down
17 changes: 2 additions & 15 deletions Server/src/services/tools/manage_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import coerce_bool


@mcp_for_unity_tool(
Expand All @@ -26,21 +27,7 @@ async def manage_editor(
# Get active instance from request state (injected by middleware)
unity_instance = get_unity_instance_from_context(ctx)

# Coerce boolean parameters defensively to tolerate 'true'/'false' strings
def _coerce_bool(value, default=None):
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
v = value.strip().lower()
if v in ("true", "1", "yes", "on"): # common truthy strings
return True
if v in ("false", "0", "no", "off"):
return False
return bool(value)

wait_for_completion = _coerce_bool(wait_for_completion)
wait_for_completion = coerce_bool(wait_for_completion)

try:
# Diagnostics: quick telemetry checks
Expand Down
28 changes: 8 additions & 20 deletions Server/src/services/tools/manage_gameobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import coerce_bool


@mcp_for_unity_tool(
Expand Down Expand Up @@ -86,19 +87,6 @@ async def manage_gameobject(
unity_instance = get_unity_instance_from_context(ctx)

# Coercers to tolerate stringified booleans and vectors
def _coerce_bool(value, default=None):
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
v = value.strip().lower()
if v in ("true", "1", "yes", "on"):
return True
if v in ("false", "0", "no", "off"):
return False
return bool(value)

def _coerce_vec(value, default=None):
if value is None:
return default
Expand Down Expand Up @@ -128,13 +116,13 @@ def _to_vec3(parts):
rotation = _coerce_vec(rotation, default=rotation)
scale = _coerce_vec(scale, default=scale)
offset = _coerce_vec(offset, default=offset)
save_as_prefab = _coerce_bool(save_as_prefab)
set_active = _coerce_bool(set_active)
find_all = _coerce_bool(find_all)
search_in_children = _coerce_bool(search_in_children)
search_inactive = _coerce_bool(search_inactive)
includeNonPublicSerialized = _coerce_bool(includeNonPublicSerialized)
world_space = _coerce_bool(world_space, default=True)
save_as_prefab = coerce_bool(save_as_prefab)
set_active = coerce_bool(set_active)
find_all = coerce_bool(find_all)
search_in_children = coerce_bool(search_in_children)
search_inactive = coerce_bool(search_inactive)
includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
world_space = coerce_bool(world_space, default=True)

# Coerce 'component_properties' from JSON string to dict for client compatibility
if isinstance(component_properties, str):
Expand Down
17 changes: 11 additions & 6 deletions Server/src/services/tools/manage_prefabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import coerce_bool


@mcp_for_unity_tool(
Expand All @@ -29,21 +30,25 @@ async def manage_prefabs(
# Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx)

try:
params: dict[str, Any] = {"action": action}

if prefab_path:
params["prefabPath"] = prefab_path
if mode:
params["mode"] = mode
if save_before_close is not None:
params["saveBeforeClose"] = bool(save_before_close)
save_before_close_val = coerce_bool(save_before_close)
if save_before_close_val is not None:
params["saveBeforeClose"] = save_before_close_val
if target:
params["target"] = target
if allow_overwrite is not None:
params["allowOverwrite"] = bool(allow_overwrite)
if search_inactive is not None:
params["searchInactive"] = bool(search_inactive)
allow_overwrite_val = coerce_bool(allow_overwrite)
if allow_overwrite_val is not None:
params["allowOverwrite"] = allow_overwrite_val
search_inactive_val = coerce_bool(search_inactive)
if search_inactive_val is not None:
params["searchInactive"] = search_inactive_val
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_prefabs", params)

if isinstance(response, dict) and response.get("success"):
Expand Down
2 changes: 1 addition & 1 deletion Server/src/services/tools/script_apply_edits.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")

# 1) read from Unity
read_resp = async_send_command_with_retry("manage_script", {
read_resp = await async_send_command_with_retry("manage_script", {
"action": "read",
"name": name,
"path": path,
Expand Down
25 changes: 25 additions & 0 deletions Server/src/services/tools/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Shared helper utilities for MCP server tools."""

from __future__ import annotations

from typing import Any


_TRUTHY = {"true", "1", "yes", "on"}
_FALSY = {"false", "0", "no", "off"}


def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
"""Attempt to coerce a loosely-typed value to a boolean."""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in _TRUTHY:
return True
if lowered in _FALSY:
return False
return default
return bool(value)
5 changes: 3 additions & 2 deletions docs/MCP_CLIENT_CONFIGURATORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This guide explains how MCP client configurators work in this repo and how to ad

It covers:

- **Typical JSON-file clients** (Cursor, VSCode GitHub Copilot, Windsurf, Kiro, Trae, Antigravity, etc.).
- **Typical JSON-file clients** (Cursor, VSCode GitHub Copilot, VSCode Insiders, Windsurf, Kiro, Trae, Antigravity, etc.).
- **Special clients** like **Claude CLI** and **Codex** that require custom logic.
- **How to add a new configurator class** so it shows up automatically in the MCP for Unity window.

Expand Down Expand Up @@ -90,6 +90,7 @@ Most MCP clients use a JSON config file that defines one or more MCP servers. Ex

- **Cursor** – `JsonFileMcpConfigurator` (global `~/.cursor/mcp.json`).
- **VSCode GitHub Copilot** – `JsonFileMcpConfigurator` with `IsVsCodeLayout = true`.
- **VSCode Insiders GitHub Copilot** – `JsonFileMcpConfigurator` with `IsVsCodeLayout = true` and Insider-specific `Code - Insiders/User/mcp.json` paths.
- **Windsurf** – `JsonFileMcpConfigurator` with Windsurf-specific flags (`HttpUrlProperty = "serverUrl"`, `DefaultUnityFields["disabled"] = false`, etc.).
- **Kiro**, **Trae**, **Antigravity (Gemini)** – JSON configs with project-specific paths and flags.

Expand Down Expand Up @@ -218,7 +219,7 @@ Override `GetInstallationSteps` to tell users how to configure the client:
- Which menu path opens the MCP settings.
- Whether they should rely on the **Configure** button or copy-paste the manual JSON.

Look at `CursorConfigurator`, `VSCodeConfigurator`, `KiroConfigurator`, `TraeConfigurator`, or `AntigravityConfigurator` for phrasing.
Look at `CursorConfigurator`, `VSCodeConfigurator`, `VSCodeInsidersConfigurator`, `KiroConfigurator`, `TraeConfigurator`, or `AntigravityConfigurator` for phrasing.

### 4. Rely on the base JSON logic

Expand Down