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 functionmain
parent
a69ce19a7b
commit
bf81319e4c
|
|
@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2c4a1b0d3b34489cbf0f8c40c49c4f3b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -22,7 +22,7 @@ namespace MCPForUnity.Editor.Services
|
|||
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 @@ namespace MCPForUnity.Editor.Services
|
|||
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 @@ namespace MCPForUnity.Editor.Services
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,33 @@ except ImportError:
|
|||
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"
|
||||
|
||||
|
|
|
|||
|
|
@ -146,6 +146,11 @@ class CustomToolService:
|
|||
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}")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from core.telemetry import is_telemetry_enabled, record_tool_usage
|
|||
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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from services.registry import mcp_for_unity_tool
|
|||
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 @@ async def manage_gameobject(
|
|||
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):
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from services.registry import mcp_for_unity_tool
|
|||
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"):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
# 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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue