diff --git a/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs new file mode 100644 index 00000000..55adb28f --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs @@ -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 GetInstallationSteps() => new List + { + "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" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta new file mode 100644 index 00000000..5fcf735e --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2c4a1b0d3b34489cbf0f8c40c49c4f3b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index 5a031f88..31927533 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -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"; @@ -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)) { @@ -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) diff --git a/Server/src/core/telemetry.py b/Server/src/core/telemetry.py index fc195d4a..9579a5de 100644 --- a/Server/src/core/telemetry.py +++ b/Server/src/core/telemetry.py @@ -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: @@ -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" diff --git a/Server/src/services/custom_tool_service.py b/Server/src/services/custom_tool_service.py index ecb5d37d..d1f37036 100644 --- a/Server/src/services/custom_tool_service.py +++ b/Server/src/services/custom_tool_service.py @@ -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, @@ -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}") diff --git a/Server/src/services/tools/manage_editor.py b/Server/src/services/tools/manage_editor.py index 852da67a..e6e95292 100644 --- a/Server/src/services/tools/manage_editor.py +++ b/Server/src/services/tools/manage_editor.py @@ -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( @@ -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 diff --git a/Server/src/services/tools/manage_gameobject.py b/Server/src/services/tools/manage_gameobject.py index b9167b6e..7774e17c 100644 --- a/Server/src/services/tools/manage_gameobject.py +++ b/Server/src/services/tools/manage_gameobject.py @@ -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( @@ -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 @@ -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): diff --git a/Server/src/services/tools/manage_prefabs.py b/Server/src/services/tools/manage_prefabs.py index 14b91ead..33f07bf7 100644 --- a/Server/src/services/tools/manage_prefabs.py +++ b/Server/src/services/tools/manage_prefabs.py @@ -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( @@ -29,6 +30,7 @@ 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} @@ -36,14 +38,17 @@ async def manage_prefabs( 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"): diff --git a/Server/src/services/tools/script_apply_edits.py b/Server/src/services/tools/script_apply_edits.py index aa6d4046..b7d589d6 100644 --- a/Server/src/services/tools/script_apply_edits.py +++ b/Server/src/services/tools/script_apply_edits.py @@ -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, diff --git a/Server/src/services/tools/utils.py b/Server/src/services/tools/utils.py new file mode 100644 index 00000000..2e2b12be --- /dev/null +++ b/Server/src/services/tools/utils.py @@ -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) diff --git a/docs/MCP_CLIENT_CONFIGURATORS.md b/docs/MCP_CLIENT_CONFIGURATORS.md index 097aee62..f79f1ccb 100644 --- a/docs/MCP_CLIENT_CONFIGURATORS.md +++ b/docs/MCP_CLIENT_CONFIGURATORS.md @@ -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. @@ -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. @@ -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