Support GitHub Copilot in VSCode Insiders + robustness improvements and bug fixes (#425)

* feat: add VSCode Insiders configurator and update documentation

* feat: add VSCode Insiders configurator metadata file

* feat: enhance telemetry and tool management with improved file handling and boolean coercion

* feat: refactor UV command handling to use BuildUvPathFromUvx method

* feat: replace custom boolean coercion logic with shared utility function

* feat: update import paths for coerce_bool utility function

* feat: enhance telemetry version retrieval and improve boolean coercion fallback logic

* feat: reapply offset and world_space parameters with coercion in manage_gameobject function
main
Jordon 2025-12-04 19:41:01 +00:00 committed by GitHub
parent a69ce19a7b
commit bf81319e4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 151 additions and 53 deletions

View File

@ -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"
};
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2c4a1b0d3b34489cbf0f8c40c49c4f3b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -22,7 +22,7 @@ namespace MCPForUnity.Editor.Services
try try
{ {
string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
string uvCommand = uvxPath.Remove(uvxPath.Length - 1, 1); string uvCommand = BuildUvPathFromUvx(uvxPath);
// Get the package name // Get the package name
string packageName = "mcp-for-unity"; string packageName = "mcp-for-unity";
@ -73,7 +73,7 @@ namespace MCPForUnity.Editor.Services
stderr = null; stderr = null;
string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
string uvPath = uvxPath.Remove(uvxPath.Length - 1, 1); string uvPath = BuildUvPathFromUvx(uvxPath);
if (!string.Equals(uvCommand, uvPath, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(uvCommand, uvPath, StringComparison.OrdinalIgnoreCase))
{ {
@ -99,6 +99,22 @@ namespace MCPForUnity.Editor.Services
return ExecPath.TryRun(uvPath, args, Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); 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() private string GetPlatformSpecificPathPrepend()
{ {
if (Application.platform == RuntimePlatform.OSXEditor) if (Application.platform == RuntimePlatform.OSXEditor)

View File

@ -34,6 +34,33 @@ except ImportError:
HAS_HTTPX = False HAS_HTTPX = False
logger = logging.getLogger("unity-mcp-telemetry") 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: def get_package_version() -> str:
@ -44,14 +71,11 @@ def get_package_version() -> str:
Default is "unknown", but that should never happen Default is "unknown", but that should never happen
""" """
try: try:
return metadata.version("MCPForUnityServer") return metadata.version(PACKAGE_NAME)
except Exception: except Exception:
# Fallback for development: read from pyproject.toml # Fallback for development: read from pyproject.toml
try: try:
pyproject_path = Path(__file__).parent / "pyproject.toml" return _version_from_local_pyproject()
with open(pyproject_path, "rb") as f:
data = tomli.load(f)
return data["project"]["version"]
except Exception: except Exception:
return "unknown" return "unknown"

View File

@ -146,6 +146,11 @@ class CustomToolService:
self._project_tools.setdefault(project_id, {})[ self._project_tools.setdefault(project_id, {})[
definition.name] = definition 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( async def _poll_until_complete(
self, self,
tool_name: str, tool_name: str,
@ -317,8 +322,16 @@ def resolve_project_id_for_unity_instance(unity_instance: str | None) -> str | N
hash_part = unity_instance hash_part = unity_instance
if hash_part: if hash_part:
# Return the hash directly as the identifier for WebSocket tools lowered = hash_part.lower()
return 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: except Exception:
logger.debug( logger.debug(
f"Failed to resolve project id via plugin hub for {unity_instance}") f"Failed to resolve project id via plugin hub for {unity_instance}")

View File

@ -6,6 +6,7 @@ from core.telemetry import is_telemetry_enabled, record_tool_usage
from services.tools import get_unity_instance_from_context from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import coerce_bool
@mcp_for_unity_tool( @mcp_for_unity_tool(
@ -26,21 +27,7 @@ async def manage_editor(
# Get active instance from request state (injected by middleware) # Get active instance from request state (injected by middleware)
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
# Coerce boolean parameters defensively to tolerate 'true'/'false' strings wait_for_completion = coerce_bool(wait_for_completion)
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)
try: try:
# Diagnostics: quick telemetry checks # Diagnostics: quick telemetry checks

View File

@ -6,6 +6,7 @@ from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import coerce_bool
@mcp_for_unity_tool( @mcp_for_unity_tool(
@ -86,19 +87,6 @@ async def manage_gameobject(
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
# Coercers to tolerate stringified booleans and vectors # 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): def _coerce_vec(value, default=None):
if value is None: if value is None:
return default return default
@ -128,13 +116,13 @@ async def manage_gameobject(
rotation = _coerce_vec(rotation, default=rotation) rotation = _coerce_vec(rotation, default=rotation)
scale = _coerce_vec(scale, default=scale) scale = _coerce_vec(scale, default=scale)
offset = _coerce_vec(offset, default=offset) offset = _coerce_vec(offset, default=offset)
save_as_prefab = _coerce_bool(save_as_prefab) save_as_prefab = coerce_bool(save_as_prefab)
set_active = _coerce_bool(set_active) set_active = coerce_bool(set_active)
find_all = _coerce_bool(find_all) find_all = coerce_bool(find_all)
search_in_children = _coerce_bool(search_in_children) search_in_children = coerce_bool(search_in_children)
search_inactive = _coerce_bool(search_inactive) search_inactive = coerce_bool(search_inactive)
includeNonPublicSerialized = _coerce_bool(includeNonPublicSerialized) includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
world_space = _coerce_bool(world_space, default=True) world_space = coerce_bool(world_space, default=True)
# Coerce 'component_properties' from JSON string to dict for client compatibility # Coerce 'component_properties' from JSON string to dict for client compatibility
if isinstance(component_properties, str): if isinstance(component_properties, str):

View File

@ -5,6 +5,7 @@ from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import coerce_bool
@mcp_for_unity_tool( @mcp_for_unity_tool(
@ -29,6 +30,7 @@ async def manage_prefabs(
# Get active instance from session state # Get active instance from session state
# Removed session_state import # Removed session_state import
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
try: try:
params: dict[str, Any] = {"action": action} params: dict[str, Any] = {"action": action}
@ -36,14 +38,17 @@ async def manage_prefabs(
params["prefabPath"] = prefab_path params["prefabPath"] = prefab_path
if mode: if mode:
params["mode"] = mode params["mode"] = mode
if save_before_close is not None: save_before_close_val = coerce_bool(save_before_close)
params["saveBeforeClose"] = bool(save_before_close) if save_before_close_val is not None:
params["saveBeforeClose"] = save_before_close_val
if target: if target:
params["target"] = target params["target"] = target
if allow_overwrite is not None: allow_overwrite_val = coerce_bool(allow_overwrite)
params["allowOverwrite"] = bool(allow_overwrite) if allow_overwrite_val is not None:
if search_inactive is not None: params["allowOverwrite"] = allow_overwrite_val
params["searchInactive"] = bool(search_inactive) 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) response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_prefabs", params)
if isinstance(response, dict) and response.get("success"): if isinstance(response, dict) and response.get("success"):

View File

@ -600,7 +600,7 @@ async def script_apply_edits(
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") 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 # 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", "action": "read",
"name": name, "name": name,
"path": path, "path": path,

View File

@ -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)

View File

@ -4,7 +4,7 @@ This guide explains how MCP client configurators work in this repo and how to ad
It covers: 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. - **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. - **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`). - **Cursor** `JsonFileMcpConfigurator` (global `~/.cursor/mcp.json`).
- **VSCode GitHub Copilot** `JsonFileMcpConfigurator` with `IsVsCodeLayout = true`. - **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.). - **Windsurf** `JsonFileMcpConfigurator` with Windsurf-specific flags (`HttpUrlProperty = "serverUrl"`, `DefaultUnityFields["disabled"] = false`, etc.).
- **Kiro**, **Trae**, **Antigravity (Gemini)** JSON configs with project-specific paths and flags. - **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. - Which menu path opens the MCP settings.
- Whether they should rely on the **Configure** button or copy-paste the manual JSON. - 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 ### 4. Rely on the base JSON logic