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
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue