Skip to content

Commit bf81319

Browse files
authored
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
1 parent a69ce19 commit bf81319

File tree

11 files changed

+151
-53
lines changed

11 files changed

+151
-53
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using MCPForUnity.Editor.Models;
5+
6+
namespace MCPForUnity.Editor.Clients.Configurators
7+
{
8+
public class VSCodeInsidersConfigurator : JsonFileMcpConfigurator
9+
{
10+
public VSCodeInsidersConfigurator() : base(new McpClient
11+
{
12+
name = "VSCode Insiders GitHub Copilot",
13+
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code - Insiders", "User", "mcp.json"),
14+
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code - Insiders", "User", "mcp.json"),
15+
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code - Insiders", "User", "mcp.json"),
16+
IsVsCodeLayout = true
17+
})
18+
{ }
19+
20+
public override IList<string> GetInstallationSteps() => new List<string>
21+
{
22+
"Install GitHub Copilot extension in VS Code Insiders",
23+
"Open or create mcp.json at the path above",
24+
"Paste the configuration JSON",
25+
"Save and restart VS Code Insiders"
26+
};
27+
}
28+
}

MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

MCPForUnity/Editor/Services/ServerManagementService.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public bool ClearUvxCache()
2222
try
2323
{
2424
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
25-
string uvCommand = uvxPath.Remove(uvxPath.Length - 1, 1);
25+
string uvCommand = BuildUvPathFromUvx(uvxPath);
2626

2727
// Get the package name
2828
string packageName = "mcp-for-unity";
@@ -73,7 +73,7 @@ private bool ExecuteUvCommand(string uvCommand, string args, out string stdout,
7373
stderr = null;
7474

7575
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
76-
string uvPath = uvxPath.Remove(uvxPath.Length - 1, 1);
76+
string uvPath = BuildUvPathFromUvx(uvxPath);
7777

7878
if (!string.Equals(uvCommand, uvPath, StringComparison.OrdinalIgnoreCase))
7979
{
@@ -99,6 +99,22 @@ private bool ExecuteUvCommand(string uvCommand, string args, out string stdout,
9999
return ExecPath.TryRun(uvPath, args, Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend);
100100
}
101101

102+
private static string BuildUvPathFromUvx(string uvxPath)
103+
{
104+
if (string.IsNullOrWhiteSpace(uvxPath))
105+
{
106+
return uvxPath;
107+
}
108+
109+
string directory = Path.GetDirectoryName(uvxPath);
110+
string extension = Path.GetExtension(uvxPath);
111+
string uvFileName = "uv" + extension;
112+
113+
return string.IsNullOrEmpty(directory)
114+
? uvFileName
115+
: Path.Combine(directory, uvFileName);
116+
}
117+
102118
private string GetPlatformSpecificPathPrepend()
103119
{
104120
if (Application.platform == RuntimePlatform.OSXEditor)

Server/src/core/telemetry.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,33 @@
3434
HAS_HTTPX = False
3535

3636
logger = logging.getLogger("unity-mcp-telemetry")
37+
PACKAGE_NAME = "MCPForUnityServer"
38+
39+
40+
def _version_from_local_pyproject() -> str:
41+
"""Locate the nearest pyproject.toml that matches our package name."""
42+
current = Path(__file__).resolve()
43+
for parent in current.parents:
44+
candidate = parent / "pyproject.toml"
45+
if not candidate.exists():
46+
continue
47+
try:
48+
with candidate.open("rb") as f:
49+
data = tomli.load(f)
50+
except (OSError, tomli.TOMLDecodeError):
51+
continue
52+
53+
project_table = data.get("project") or {}
54+
poetry_table = data.get("tool", {}).get("poetry", {})
55+
56+
project_name = project_table.get("name") or poetry_table.get("name")
57+
if project_name and project_name.lower() != PACKAGE_NAME.lower():
58+
continue
59+
60+
version = project_table.get("version") or poetry_table.get("version")
61+
if version:
62+
return version
63+
raise FileNotFoundError("pyproject.toml not found for MCPForUnityServer")
3764

3865

3966
def get_package_version() -> str:
@@ -44,14 +71,11 @@ def get_package_version() -> str:
4471
Default is "unknown", but that should never happen
4572
"""
4673
try:
47-
return metadata.version("MCPForUnityServer")
74+
return metadata.version(PACKAGE_NAME)
4875
except Exception:
4976
# Fallback for development: read from pyproject.toml
5077
try:
51-
pyproject_path = Path(__file__).parent / "pyproject.toml"
52-
with open(pyproject_path, "rb") as f:
53-
data = tomli.load(f)
54-
return data["project"]["version"]
78+
return _version_from_local_pyproject()
5579
except Exception:
5680
return "unknown"
5781

Server/src/services/custom_tool_service.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ def _register_tool(self, project_id: str, definition: ToolDefinitionModel) -> No
146146
self._project_tools.setdefault(project_id, {})[
147147
definition.name] = definition
148148

149+
def get_project_id_for_hash(self, project_hash: str | None) -> str | None:
150+
if not project_hash:
151+
return None
152+
return self._hash_to_project.get(project_hash.lower())
153+
149154
async def _poll_until_complete(
150155
self,
151156
tool_name: str,
@@ -317,8 +322,16 @@ def resolve_project_id_for_unity_instance(unity_instance: str | None) -> str | N
317322
hash_part = unity_instance
318323

319324
if hash_part:
320-
# Return the hash directly as the identifier for WebSocket tools
321-
return hash_part.lower()
325+
lowered = hash_part.lower()
326+
mapped: Optional[str] = None
327+
try:
328+
service = CustomToolService.get_instance()
329+
mapped = service.get_project_id_for_hash(lowered)
330+
except RuntimeError:
331+
mapped = None
332+
if mapped:
333+
return mapped
334+
return lowered
322335
except Exception:
323336
logger.debug(
324337
f"Failed to resolve project id via plugin hub for {unity_instance}")

Server/src/services/tools/manage_editor.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from services.tools import get_unity_instance_from_context
77
from transport.unity_transport import send_with_unity_instance
88
from transport.legacy.unity_connection import async_send_command_with_retry
9+
from services.tools.utils import coerce_bool
910

1011

1112
@mcp_for_unity_tool(
@@ -26,21 +27,7 @@ async def manage_editor(
2627
# Get active instance from request state (injected by middleware)
2728
unity_instance = get_unity_instance_from_context(ctx)
2829

29-
# Coerce boolean parameters defensively to tolerate 'true'/'false' strings
30-
def _coerce_bool(value, default=None):
31-
if value is None:
32-
return default
33-
if isinstance(value, bool):
34-
return value
35-
if isinstance(value, str):
36-
v = value.strip().lower()
37-
if v in ("true", "1", "yes", "on"): # common truthy strings
38-
return True
39-
if v in ("false", "0", "no", "off"):
40-
return False
41-
return bool(value)
42-
43-
wait_for_completion = _coerce_bool(wait_for_completion)
30+
wait_for_completion = coerce_bool(wait_for_completion)
4431

4532
try:
4633
# Diagnostics: quick telemetry checks

Server/src/services/tools/manage_gameobject.py

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from services.tools import get_unity_instance_from_context
77
from transport.unity_transport import send_with_unity_instance
88
from transport.legacy.unity_connection import async_send_command_with_retry
9+
from services.tools.utils import coerce_bool
910

1011

1112
@mcp_for_unity_tool(
@@ -86,19 +87,6 @@ async def manage_gameobject(
8687
unity_instance = get_unity_instance_from_context(ctx)
8788

8889
# Coercers to tolerate stringified booleans and vectors
89-
def _coerce_bool(value, default=None):
90-
if value is None:
91-
return default
92-
if isinstance(value, bool):
93-
return value
94-
if isinstance(value, str):
95-
v = value.strip().lower()
96-
if v in ("true", "1", "yes", "on"):
97-
return True
98-
if v in ("false", "0", "no", "off"):
99-
return False
100-
return bool(value)
101-
10290
def _coerce_vec(value, default=None):
10391
if value is None:
10492
return default
@@ -128,13 +116,13 @@ def _to_vec3(parts):
128116
rotation = _coerce_vec(rotation, default=rotation)
129117
scale = _coerce_vec(scale, default=scale)
130118
offset = _coerce_vec(offset, default=offset)
131-
save_as_prefab = _coerce_bool(save_as_prefab)
132-
set_active = _coerce_bool(set_active)
133-
find_all = _coerce_bool(find_all)
134-
search_in_children = _coerce_bool(search_in_children)
135-
search_inactive = _coerce_bool(search_inactive)
136-
includeNonPublicSerialized = _coerce_bool(includeNonPublicSerialized)
137-
world_space = _coerce_bool(world_space, default=True)
119+
save_as_prefab = coerce_bool(save_as_prefab)
120+
set_active = coerce_bool(set_active)
121+
find_all = coerce_bool(find_all)
122+
search_in_children = coerce_bool(search_in_children)
123+
search_inactive = coerce_bool(search_inactive)
124+
includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
125+
world_space = coerce_bool(world_space, default=True)
138126

139127
# Coerce 'component_properties' from JSON string to dict for client compatibility
140128
if isinstance(component_properties, str):

Server/src/services/tools/manage_prefabs.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from services.tools import get_unity_instance_from_context
66
from transport.unity_transport import send_with_unity_instance
77
from transport.legacy.unity_connection import async_send_command_with_retry
8+
from services.tools.utils import coerce_bool
89

910

1011
@mcp_for_unity_tool(
@@ -29,21 +30,25 @@ async def manage_prefabs(
2930
# Get active instance from session state
3031
# Removed session_state import
3132
unity_instance = get_unity_instance_from_context(ctx)
33+
3234
try:
3335
params: dict[str, Any] = {"action": action}
3436

3537
if prefab_path:
3638
params["prefabPath"] = prefab_path
3739
if mode:
3840
params["mode"] = mode
39-
if save_before_close is not None:
40-
params["saveBeforeClose"] = bool(save_before_close)
41+
save_before_close_val = coerce_bool(save_before_close)
42+
if save_before_close_val is not None:
43+
params["saveBeforeClose"] = save_before_close_val
4144
if target:
4245
params["target"] = target
43-
if allow_overwrite is not None:
44-
params["allowOverwrite"] = bool(allow_overwrite)
45-
if search_inactive is not None:
46-
params["searchInactive"] = bool(search_inactive)
46+
allow_overwrite_val = coerce_bool(allow_overwrite)
47+
if allow_overwrite_val is not None:
48+
params["allowOverwrite"] = allow_overwrite_val
49+
search_inactive_val = coerce_bool(search_inactive)
50+
if search_inactive_val is not None:
51+
params["searchInactive"] = search_inactive_val
4752
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_prefabs", params)
4853

4954
if isinstance(response, dict) and response.get("success"):

Server/src/services/tools/script_apply_edits.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,7 @@ def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str
600600
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")
601601

602602
# 1) read from Unity
603-
read_resp = async_send_command_with_retry("manage_script", {
603+
read_resp = await async_send_command_with_retry("manage_script", {
604604
"action": "read",
605605
"name": name,
606606
"path": path,

Server/src/services/tools/utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Shared helper utilities for MCP server tools."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
8+
_TRUTHY = {"true", "1", "yes", "on"}
9+
_FALSY = {"false", "0", "no", "off"}
10+
11+
12+
def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
13+
"""Attempt to coerce a loosely-typed value to a boolean."""
14+
if value is None:
15+
return default
16+
if isinstance(value, bool):
17+
return value
18+
if isinstance(value, str):
19+
lowered = value.strip().lower()
20+
if lowered in _TRUTHY:
21+
return True
22+
if lowered in _FALSY:
23+
return False
24+
return default
25+
return bool(value)

0 commit comments

Comments
 (0)