Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ namespace MCPForUnity.Editor.Helpers
internal static class ProjectIdentityUtility
{
private const string SessionPrefKey = EditorPrefKeys.WebSocketSessionId;
private static bool _legacyKeyCleared;
private static string _cachedProjectName = "Unknown";
private static string _cachedProjectHash = "default";
private static bool _cacheScheduled;
Expand Down Expand Up @@ -69,6 +70,7 @@ private static void UpdateIdentityCache()
/// </summary>
public static string GetProjectHash()
{
EnsureIdentityCache();
return _cachedProjectHash;
}

Expand Down Expand Up @@ -122,16 +124,21 @@ private static string ComputeProjectName(string dataPath)

/// <summary>
/// Retrieves a persistent session id for the plugin, creating one if absent.
/// The session id is unique per project (scoped by project hash).
/// </summary>
public static string GetOrCreateSessionId()
{
try
{
string sessionId = EditorPrefs.GetString(SessionPrefKey, string.Empty);
// Make the session ID project-specific by including the project hash in the key
string projectHash = GetProjectHash();
string projectSpecificKey = $"{SessionPrefKey}_{projectHash}";

string sessionId = EditorPrefs.GetString(projectSpecificKey, string.Empty);
if (string.IsNullOrEmpty(sessionId))
{
sessionId = Guid.NewGuid().ToString();
EditorPrefs.SetString(SessionPrefKey, sessionId);
EditorPrefs.SetString(projectSpecificKey, sessionId);
}
return sessionId;
}
Expand All @@ -149,15 +156,69 @@ public static void ResetSessionId()
{
try
{
if (EditorPrefs.HasKey(SessionPrefKey))
// Clear the project-specific session ID
string projectHash = GetProjectHash();
string projectSpecificKey = $"{SessionPrefKey}_{projectHash}";

if (EditorPrefs.HasKey(projectSpecificKey))
{
EditorPrefs.DeleteKey(projectSpecificKey);
}

if (!_legacyKeyCleared && EditorPrefs.HasKey(SessionPrefKey))
{
EditorPrefs.DeleteKey(SessionPrefKey);
_legacyKeyCleared = true;
}
}
catch
{
// Ignore
}
}

private static void EnsureIdentityCache()
{
// When Application.dataPath is unavailable (e.g., batch mode) we fall back to
// hashing the current working directory/Assets path so each project still
// derives a deterministic, per-project session id rather than sharing "default".
if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default")
{
return;
}

UpdateIdentityCache();

if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default")
{
return;
}

string fallback = TryComputeFallbackProjectHash();
if (!string.IsNullOrEmpty(fallback))
{
_cachedProjectHash = fallback;
}
}

private static string TryComputeFallbackProjectHash()
{
try
{
string workingDirectory = Directory.GetCurrentDirectory();
if (string.IsNullOrEmpty(workingDirectory))
{
return "default";
}

// Normalise trailing separators so hashes remain stable
workingDirectory = workingDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return ComputeProjectHash(Path.Combine(workingDirectory, "Assets"));
}
catch
{
return "default";
}
}
}
}
2 changes: 0 additions & 2 deletions Server/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
http_port = int(os.environ.get("UNITY_MCP_HTTP_PORT", "8080"))
logger.info(
f"HTTP tool registry will be available on http://{http_host}:{http_port}")
else:
logger.info("HTTP server disabled - using stdio transport")

global _plugin_registry
if _plugin_registry is None:
Expand Down
103 changes: 81 additions & 22 deletions Server/src/services/resources/unity_instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
from fastmcp import Context
from services.registry import mcp_for_unity_resource
from transport.legacy.unity_connection import get_unity_connection_pool
from transport.plugin_hub import PluginHub
from transport.unity_transport import _is_http_transport as _core_is_http_transport


def _is_http_transport() -> bool:
return _core_is_http_transport()


@mcp_for_unity_resource(
Expand All @@ -20,42 +26,95 @@ async def unity_instances(ctx: Context) -> dict[str, Any]:
Returns information about each instance including:
- id: Unique identifier (ProjectName@hash)
- name: Project name
- path: Full project path
- path: Full project path (stdio only)
- hash: 8-character hash of project path
- port: TCP port number
- status: Current status (running, reloading, etc.)
- last_heartbeat: Last heartbeat timestamp
- port: TCP port number (stdio only)
- status: Current status (running, reloading, etc.) (stdio only)
- last_heartbeat: Last heartbeat timestamp (stdio only)
- unity_version: Unity version (if available)
- connected_at: Connection timestamp (HTTP only)

Returns:
Dictionary containing list of instances and metadata
"""
await ctx.info("Listing Unity instances")

try:
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=False)
if _is_http_transport():
# HTTP/WebSocket transport: query PluginHub
sessions_data = await PluginHub.get_sessions()
sessions = sessions_data.get("sessions", {})

# Check for duplicate project names
name_counts = {}
for inst in instances:
name_counts[inst.name] = name_counts.get(inst.name, 0) + 1
instances = []
for session_id, session_info in sessions.items():
project = session_info.get(
"project") or session_info.get("project_name")
project_hash = session_info.get("hash")

duplicates = [name for name, count in name_counts.items() if count > 1]
if not project or not project_hash:
raise ValueError(
"PluginHub session missing required 'project' or 'hash' fields."
)

result = {
"success": True,
"instance_count": len(instances),
"instances": [inst.to_dict() for inst in instances],
}
instances.append({
"id": f"{project}@{project_hash}",
"name": project,
"hash": project_hash,
"unity_version": session_info.get("unity_version"),
"connected_at": session_info.get("connected_at"),
"session_id": session_id,
})

# Check for duplicate project names
name_counts = {}
for inst in instances:
name_counts[inst["name"]] = name_counts.get(
inst["name"], 0) + 1

duplicates = [name for name,
count in name_counts.items() if count > 1]

result = {
"success": True,
"transport": "http",
"instance_count": len(instances),
"instances": instances,
}

if duplicates:
result["warning"] = (
f"Multiple instances found with duplicate project names: {duplicates}. "
f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
)

return result
else:
# Stdio/TCP transport: query connection pool
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=False)

# Check for duplicate project names
name_counts = {}
for inst in instances:
name_counts[inst.name] = name_counts.get(inst.name, 0) + 1

duplicates = [name for name,
count in name_counts.items() if count > 1]

result = {
"success": True,
"transport": "stdio",
"instance_count": len(instances),
"instances": [inst.to_dict() for inst in instances],
}

if duplicates:
result["warning"] = (
f"Multiple instances found with duplicate project names: {duplicates}. "
f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
)
if duplicates:
result["warning"] = (
f"Multiple instances found with duplicate project names: {duplicates}. "
f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
)

return result
return result

except Exception as e:
await ctx.error(f"Error listing Unity instances: {e}")
Expand Down
18 changes: 9 additions & 9 deletions Server/src/services/tools/manage_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,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
import unity_connection
import transport.legacy.unity_connection


def _split_uri(uri: str) -> tuple[str, str]:
Expand Down Expand Up @@ -107,7 +107,7 @@ def _needs_normalization(arr: list[dict[str, Any]]) -> bool:
if _needs_normalization(edits):
# Read file to support index->line/col conversion when needed
read_resp = await send_with_unity_instance(
unity_connection.async_send_command_with_retry,
transport.legacy.unity_connection.async_send_command_with_retry,
unity_instance,
"manage_script",
{
Expand Down Expand Up @@ -313,7 +313,7 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool:
}
params = {k: v for k, v in params.items() if v is not None}
resp = await send_with_unity_instance(
unity_connection.async_send_command_with_retry,
transport.legacy.unity_connection.async_send_command_with_retry,
unity_instance,
"manage_script",
params,
Expand Down Expand Up @@ -349,7 +349,7 @@ async def _flip_async():
st = _latest_status()
if st and st.get("reloading"):
return
await unity_connection.async_send_command_with_retry(
await transport.legacy.unity_connection.async_send_command_with_retry(
"execute_menu_item",
{"menuPath": "MCP/Flip Reload Sentinel"},
max_retries=0,
Expand Down Expand Up @@ -402,7 +402,7 @@ async def create_script(
params["contentsEncoded"] = True
params = {k: v for k, v in params.items() if v is not None}
resp = await send_with_unity_instance(
unity_connection.async_send_command_with_retry,
transport.legacy.unity_connection.async_send_command_with_retry,
unity_instance,
"manage_script",
params,
Expand All @@ -423,7 +423,7 @@ async def delete_script(
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
params = {"action": "delete", "name": name, "path": directory}
resp = await send_with_unity_instance(
unity_connection.async_send_command_with_retry,
transport.legacy.unity_connection.async_send_command_with_retry,
unity_instance,
"manage_script",
params,
Expand Down Expand Up @@ -454,7 +454,7 @@ async def validate_script(
"level": level,
}
resp = await send_with_unity_instance(
unity_connection.async_send_command_with_retry,
transport.legacy.unity_connection.async_send_command_with_retry,
unity_instance,
"manage_script",
params,
Expand Down Expand Up @@ -507,7 +507,7 @@ async def manage_script(
params = {k: v for k, v in params.items() if v is not None}

response = await send_with_unity_instance(
unity_connection.async_send_command_with_retry,
transport.legacy.unity_connection.async_send_command_with_retry,
unity_instance,
"manage_script",
params,
Expand Down Expand Up @@ -581,7 +581,7 @@ async def get_sha(
name, directory = _split_uri(uri)
params = {"action": "get_sha", "name": name, "path": directory}
resp = await send_with_unity_instance(
unity_connection.async_send_command_with_retry,
transport.legacy.unity_connection.async_send_command_with_retry,
unity_instance,
"manage_script",
params,
Expand Down
Loading