Skip to content
Merged
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
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