diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index f1dc2eb98..eb4ba2b52 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -423,7 +423,9 @@ private void Register() else { var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); - args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" {packageName}"; + bool devForceRefresh = GetDevModeForceRefresh(); + string devFlags = devForceRefresh ? "--no-cache --refresh " : string.Empty; + args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}"; } string projectDir = Path.GetDirectoryName(Application.dataPath); @@ -537,14 +539,23 @@ public override string GetManualSnippet() } string gitUrl = AssetPathUtility.GetMcpServerGitUrl(); + bool devForceRefresh = GetDevModeForceRefresh(); + string devFlags = devForceRefresh ? "--no-cache --refresh " : string.Empty; + return "# Register the MCP server with Claude Code:\n" + - $"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" mcp-for-unity\n\n" + + $"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" mcp-for-unity\n\n" + "# Unregister the MCP server:\n" + "claude mcp remove UnityMCP\n\n" + "# List registered servers:\n" + "claude mcp list # Only works when claude is run in the project's directory"; } + private static bool GetDevModeForceRefresh() + { + try { return EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } + catch { return false; } + } + public override IList GetInstallationSteps() => new List { "Ensure Claude CLI is installed", diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index d6edcf1aa..25542ab06 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -20,6 +20,7 @@ internal static class EditorPrefKeys internal const string SessionId = "MCPForUnity.SessionId"; internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl"; internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride"; + internal const string DevModeForceServerRefresh = "MCPForUnity.DevModeForceServerRefresh"; internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath"; internal const string PackageDeployLastBackupPath = "MCPForUnity.PackageDeploy.LastBackupPath"; diff --git a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs index 75b243b85..3a8a6cf65 100644 --- a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs +++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs @@ -2,9 +2,11 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Services; using MCPForUnity.External.Tommy; using UnityEditor; +using UnityEngine; namespace MCPForUnity.Editor.Helpers { @@ -15,6 +17,26 @@ namespace MCPForUnity.Editor.Helpers /// public static class CodexConfigHelper { + private static bool GetDevModeForceRefresh() + { + try + { + return EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); + } + catch + { + return false; + } + } + + private static void AddDevModeArgs(TomlArray args, bool devForceRefresh) + { + if (args == null) return; + if (!devForceRefresh) return; + args.Add(new TomlString { Value = "--no-cache" }); + args.Add(new TomlString { Value = "--refresh" }); + } + public static string BuildCodexServerBlock(string uvPath) { var table = new TomlTable(); @@ -37,9 +59,12 @@ public static string BuildCodexServerBlock(string uvPath) { // Stdio mode: Use command and args var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + bool devForceRefresh = GetDevModeForceRefresh(); + unityMCP["command"] = uvxPath; var args = new TomlArray(); + AddDevModeArgs(args, devForceRefresh); if (!string.IsNullOrEmpty(fromUrl)) { args.Add(new TomlString { Value = "--from" }); @@ -184,9 +209,12 @@ private static TomlTable CreateUnityMcpTable(string uvPath) { // Stdio mode: Use command and args var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + bool devForceRefresh = GetDevModeForceRefresh(); + unityMCP["command"] = new TomlString { Value = uvxPath }; var argsArray = new TomlArray(); + AddDevModeArgs(argsArray, devForceRefresh); if (!string.IsNullOrEmpty(fromUrl)) { argsArray.Add(new TomlString { Value = "--from" }); diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 3c0ba7058..067eed9c6 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -81,7 +81,10 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl // Stdio mode: Use uvx command var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); - var toolArgs = BuildUvxArgs(fromUrl, packageName); + bool devForceRefresh = false; + try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { } + + var toolArgs = BuildUvxArgs(fromUrl, packageName, devForceRefresh); if (ShouldUseWindowsCmdShim(client)) { @@ -149,15 +152,23 @@ private static JObject EnsureObject(JObject parent, string name) return created; } - private static IList BuildUvxArgs(string fromUrl, string packageName) + private static IList BuildUvxArgs(string fromUrl, string packageName, bool devForceRefresh) { - var args = new List { packageName }; - + // Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating). + // `--no-cache` is the key flag; `--refresh` ensures metadata is revalidated. + // Keep ordering consistent with other uvx builders: dev flags first, then --from , then package name. + var args = new List(); + if (devForceRefresh) + { + args.Add("--no-cache"); + args.Add("--refresh"); + } if (!string.IsNullOrEmpty(fromUrl)) { - args.Insert(0, fromUrl); - args.Insert(0, "--from"); + args.Add("--from"); + args.Add(fromUrl); } + args.Add(packageName); args.Add("--transport"); args.Add("stdio"); diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index 319275331..b081dada0 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Collections.Generic; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using UnityEditor; @@ -171,15 +172,7 @@ public bool StartLocalHttpServer() // First, try to stop any existing server StopLocalHttpServer(); - // Clear the cache to ensure we get a fresh version - try - { - ClearUvxCache(); - } - catch (Exception ex) - { - McpLog.Warn($"Failed to clear cache before starting server: {ex.Message}"); - } + // Note: Dev mode cache-busting is handled by `uvx --no-cache --refresh` in the generated command. if (EditorUtility.DisplayDialog( "Start Local HTTP Server", @@ -237,20 +230,47 @@ public bool StopLocalHttpServer() return false; } - McpLog.Info($"Attempting to stop any process listening on local port {port}. This will terminate the owning process even if it is not the MCP server."); + // Guardrails: + // - Never terminate the Unity Editor process. + // - Only terminate processes that look like the MCP server (uv/uvx/python running mcp-for-unity). + // This prevents accidental termination of unrelated services (including Unity itself). + int unityPid = GetCurrentProcessIdSafe(); - int pid = GetProcessIdForPort(port); - if (pid > 0) - { - KillProcess(pid); - McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pid})"); - return true; - } - else + var pids = GetListeningProcessIdsForPort(port); + if (pids.Count == 0) { McpLog.Info($"No process found listening on port {port}"); return false; } + + bool stoppedAny = false; + foreach (var pid in pids) + { + if (pid <= 0) continue; + if (unityPid > 0 && pid == unityPid) + { + McpLog.Warn($"Refusing to stop port {port}: owning PID appears to be the Unity Editor process (PID {pid})."); + continue; + } + + if (!LooksLikeMcpServerProcess(pid)) + { + McpLog.Warn($"Refusing to stop port {port}: owning PID {pid} does not look like mcp-for-unity (uvx/uv/python)."); + continue; + } + + if (TerminateProcess(pid)) + { + McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pid})"); + stoppedAny = true; + } + else + { + McpLog.Warn($"Failed to stop process PID {pid} on port {port}"); + } + } + + return stoppedAny; } catch (Exception ex) { @@ -259,8 +279,9 @@ public bool StopLocalHttpServer() } } - private int GetProcessIdForPort(int port) + private List GetListeningProcessIdsForPort(int port) { + var results = new List(); try { string stdout, stderr; @@ -280,7 +301,7 @@ private int GetProcessIdForPort(int port) var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length > 0 && int.TryParse(parts[parts.Length - 1], out int pid)) { - return pid; + results.Add(pid); } } } @@ -288,12 +309,13 @@ private int GetProcessIdForPort(int port) } else { - // lsof -i : -t + // lsof: only return LISTENers (avoids capturing random clients) // Use /usr/sbin/lsof directly as it might not be in PATH for Unity string lsofPath = "/usr/sbin/lsof"; if (!System.IO.File.Exists(lsofPath)) lsofPath = "lsof"; // Fallback - success = ExecPath.TryRun(lsofPath, $"-i :{port} -t", Application.dataPath, out stdout, out stderr); + // -nP: avoid DNS/service name lookups; faster and less error-prone + success = ExecPath.TryRun(lsofPath, $"-nP -iTCP:{port} -sTCP:LISTEN -t", Application.dataPath, out stdout, out stderr); if (success && !string.IsNullOrWhiteSpace(stdout)) { var pidStrings = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); @@ -301,12 +323,7 @@ private int GetProcessIdForPort(int port) { if (int.TryParse(pidString.Trim(), out int pid)) { - if (pidStrings.Length > 1) - { - McpLog.Debug($"Multiple processes found on port {port}; attempting to stop PID {pid} returned by lsof -t."); - } - - return pid; + results.Add(pid); } } } @@ -316,26 +333,96 @@ private int GetProcessIdForPort(int port) { McpLog.Warn($"Error checking port {port}: {ex.Message}"); } - return -1; + return results.Distinct().ToList(); + } + + private static int GetCurrentProcessIdSafe() + { + try { return System.Diagnostics.Process.GetCurrentProcess().Id; } + catch { return -1; } + } + + private bool LooksLikeMcpServerProcess(int pid) + { + try + { + // Windows best-effort: tasklist /FI "PID eq X" + if (Application.platform == RuntimePlatform.WindowsEditor) + { + if (ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var stdout, out var stderr, 5000)) + { + string combined = (stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty); + combined = combined.ToLowerInvariant(); + // Common process names: python.exe, uv.exe, uvx.exe + return combined.Contains("python") || combined.Contains("uvx") || combined.Contains("uv.exe") || combined.Contains("uvx.exe"); + } + return false; + } + + // macOS/Linux: ps -p pid -o comm= -o args= + if (ExecPath.TryRun("ps", $"-p {pid} -o comm= -o args=", Application.dataPath, out var psOut, out var psErr, 5000)) + { + string s = (psOut ?? string.Empty).Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(s)) + { + s = (psErr ?? string.Empty).Trim().ToLowerInvariant(); + } + + // Explicitly never kill Unity / Unity Hub processes + if (s.Contains("unity") || s.Contains("unityhub") || s.Contains("unity hub")) + { + return false; + } + + // Positive indicators + bool mentionsUvx = s.Contains("uvx") || s.Contains(" uvx "); + bool mentionsUv = s.Contains("uv ") || s.Contains("/uv"); + bool mentionsPython = s.Contains("python"); + bool mentionsMcp = s.Contains("mcp-for-unity") || s.Contains("mcp_for_unity") || s.Contains("mcp for unity"); + bool mentionsTransport = s.Contains("--transport") && s.Contains("http"); + + // Accept if it looks like uv/uvx/python launching our server package/entrypoint + if ((mentionsUvx || mentionsUv || mentionsPython) && (mentionsMcp || mentionsTransport)) + { + return true; + } + } + } + catch { } + + return false; } - private void KillProcess(int pid) + private bool TerminateProcess(int pid) { try { string stdout, stderr; if (Application.platform == RuntimePlatform.WindowsEditor) { - ExecPath.TryRun("taskkill", $"/F /PID {pid}", Application.dataPath, out stdout, out stderr); + // taskkill without /F first; fall back to /F if needed. + bool ok = ExecPath.TryRun("taskkill", $"/PID {pid}", Application.dataPath, out stdout, out stderr); + if (!ok) + { + ok = ExecPath.TryRun("taskkill", $"/F /PID {pid}", Application.dataPath, out stdout, out stderr); + } + return ok; } else { - ExecPath.TryRun("kill", $"-9 {pid}", Application.dataPath, out stdout, out stderr); + // Try a graceful termination first, then escalate. + bool ok = ExecPath.TryRun("kill", $"-15 {pid}", Application.dataPath, out stdout, out stderr); + if (!ok) + { + ok = ExecPath.TryRun("kill", $"-9 {pid}", Application.dataPath, out stdout, out stderr); + } + return ok; } } catch (Exception ex) { McpLog.Error($"Error killing process {pid}: {ex.Message}"); + return false; } } @@ -368,9 +455,13 @@ public bool TryGetLocalHttpServerCommand(out string command, out string error) return false; } + bool devForceRefresh = false; + try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { } + + string devFlags = devForceRefresh ? "--no-cache --refresh " : string.Empty; string args = string.IsNullOrEmpty(fromUrl) - ? $"{packageName} --transport http --http-url {httpUrl}" - : $"--from {fromUrl} {packageName} --transport http --http-url {httpUrl}"; + ? $"{devFlags}{packageName} --transport http --http-url {httpUrl}" + : $"{devFlags}--from {fromUrl} {packageName} --transport http --http-url {httpUrl}"; command = $"{uvxPath} {args}"; return true; diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs b/MCPForUnity/Editor/Tools/ManageGameObject.cs index 77f3fde52..18497ec65 100644 --- a/MCPForUnity/Editor/Tools/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs @@ -180,8 +180,44 @@ public static object HandleCommand(JObject @params) return new ErrorResponse( "'target' parameter required for get_components." ); - // Pass the includeNonPublicSerialized flag here - return GetComponentsFromTarget(getCompTarget, searchMethod, includeNonPublicSerialized); + // Paging + safety: return metadata by default; deep fields are opt-in. + int CoerceInt(JToken t, int @default) + { + if (t == null || t.Type == JTokenType.Null) return @default; + try + { + if (t.Type == JTokenType.Integer) return t.Value(); + var s = t.ToString().Trim(); + if (s.Length == 0) return @default; + if (int.TryParse(s, out var i)) return i; + if (double.TryParse(s, out var d)) return (int)d; + } + catch { } + return @default; + } + bool CoerceBool(JToken t, bool @default) + { + if (t == null || t.Type == JTokenType.Null) return @default; + try + { + if (t.Type == JTokenType.Boolean) return t.Value(); + var s = t.ToString().Trim(); + if (s.Length == 0) return @default; + if (bool.TryParse(s, out var b)) return b; + if (s == "1") return true; + if (s == "0") return false; + } + catch { } + return @default; + } + + int pageSize = CoerceInt(@params["pageSize"] ?? @params["page_size"], 25); + int cursor = CoerceInt(@params["cursor"], 0); + int maxComponents = CoerceInt(@params["maxComponents"] ?? @params["max_components"], 50); + bool includeProperties = CoerceBool(@params["includeProperties"] ?? @params["include_properties"], false); + + // Pass the includeNonPublicSerialized flag through, but only used if includeProperties is true. + return GetComponentsFromTarget(getCompTarget, searchMethod, includeNonPublicSerialized, pageSize, cursor, maxComponents, includeProperties); case "get_component": string getSingleCompTarget = targetToken?.ToString(); if (getSingleCompTarget == null) @@ -1191,7 +1227,15 @@ string searchMethod return new SuccessResponse($"Found {results.Count} GameObject(s).", results); } - private static object GetComponentsFromTarget(string target, string searchMethod, bool includeNonPublicSerialized = true) + private static object GetComponentsFromTarget( + string target, + string searchMethod, + bool includeNonPublicSerialized = true, + int pageSize = 25, + int cursor = 0, + int maxComponents = 50, + bool includeProperties = false + ) { GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) @@ -1203,57 +1247,90 @@ private static object GetComponentsFromTarget(string target, string searchMethod try { - // --- Get components, immediately copy to list, and null original array --- - Component[] originalComponents = targetGo.GetComponents(); - List componentsToIterate = new List(originalComponents ?? Array.Empty()); // Copy immediately, handle null case - int componentCount = componentsToIterate.Count; - originalComponents = null; // Null the original reference - // Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop..."); - // --- End Copy and Null --- + int resolvedPageSize = Mathf.Clamp(pageSize, 1, 200); + int resolvedCursor = Mathf.Max(0, cursor); + int resolvedMaxComponents = Mathf.Clamp(maxComponents, 1, 500); + int effectiveTake = Mathf.Min(resolvedPageSize, resolvedMaxComponents); + + // Build a stable list once; pagination is applied to this list. + var all = targetGo.GetComponents(); + var components = new List(all?.Length ?? 0); + if (all != null) + { + for (int i = 0; i < all.Length; i++) + { + if (all[i] != null) components.Add(all[i]); + } + } + + int total = components.Count; + if (resolvedCursor > total) resolvedCursor = total; + int end = Mathf.Min(total, resolvedCursor + effectiveTake); - var componentData = new List(); + var items = new List(Mathf.Max(0, end - resolvedCursor)); - for (int i = componentCount - 1; i >= 0; i--) // Iterate backwards over the COPY + // If caller explicitly asked for properties, we still enforce a conservative payload budget. + const int maxPayloadChars = 250_000; // ~250KB assuming 1 char ~= 1 byte ASCII-ish + int payloadChars = 0; + + for (int i = resolvedCursor; i < end; i++) { - Component c = componentsToIterate[i]; // Use the copy - if (c == null) + var c = components[i]; + if (c == null) continue; + + if (!includeProperties) { - // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] Encountered a null component at index {i} on {targetGo.name}. Skipping."); - continue; // Safety check + items.Add(BuildComponentMetadata(c)); + continue; } - // Debug.Log($"[GetComponentsFromTarget REVERSE for] Processing component: {c.GetType()?.FullName ?? "null"} (ID: {c.GetInstanceID()}) at index {i} on {targetGo.name}"); + try { var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized); - if (data != null) // Ensure GetComponentData didn't return null + if (data == null) continue; + + // Rough cap to keep responses from exploding even when includeProperties is true. + var token = JToken.FromObject(data); + int addChars = token.ToString(Newtonsoft.Json.Formatting.None).Length; + if (payloadChars + addChars > maxPayloadChars && items.Count > 0) { - componentData.Insert(0, data); // Insert at beginning to maintain original order in final list + // Stop early; next_cursor will allow fetching more (or caller can use get_component). + end = i; + break; } - // else - // { - // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] GetComponentData returned null for component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}. Skipping addition."); - // } + payloadChars += addChars; + items.Add(token); } catch (Exception ex) { - Debug.LogError($"[GetComponentsFromTarget REVERSE for] Error processing component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}: {ex.Message}\n{ex.StackTrace}"); - // Optionally add placeholder data or just skip - componentData.Insert(0, new JObject( // Insert error marker at beginning - new JProperty("typeName", c.GetType().FullName + " (Serialization Error)"), - new JProperty("instanceID", c.GetInstanceID()), - new JProperty("error", ex.Message) - )); + // Avoid throwing; mark the component as failed. + items.Add( + new JObject( + new JProperty("typeName", c.GetType().FullName + " (Serialization Error)"), + new JProperty("instanceID", c.GetInstanceID()), + new JProperty("error", ex.Message) + ) + ); } } - // Debug.Log($"[GetComponentsFromTarget] Finished REVERSE for loop."); - // Cleanup the list we created - componentsToIterate.Clear(); - componentsToIterate = null; + bool truncated = end < total; + string nextCursor = truncated ? end.ToString() : null; + + var payload = new + { + cursor = resolvedCursor, + pageSize = effectiveTake, + next_cursor = nextCursor, + truncated = truncated, + total = total, + includeProperties = includeProperties, + items = items, + }; return new SuccessResponse( - $"Retrieved {componentData.Count} components from '{targetGo.name}'.", - componentData // List was built in original order + $"Retrieved components page from '{targetGo.name}'.", + payload ); } catch (Exception e) @@ -1264,6 +1341,21 @@ private static object GetComponentsFromTarget(string target, string searchMethod } } + private static object BuildComponentMetadata(Component c) + { + if (c == null) return null; + var d = new Dictionary + { + { "typeName", c.GetType().FullName }, + { "instanceID", c.GetInstanceID() }, + }; + if (c is Behaviour b) + { + d["enabled"] = b.enabled; + } + return d; + } + private static object GetSingleComponentFromTarget(string target, string searchMethod, string componentName, bool includeNonPublicSerialized = true) { GameObject targetGo = FindObjectInternal(target, searchMethod); diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index eb41a8fb8..2c10f45bc 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -26,6 +26,15 @@ private sealed class SceneCommand public int? buildIndex { get; set; } public string fileName { get; set; } = string.Empty; public int? superSize { get; set; } + + // get_hierarchy paging + safety (summary-first) + public JToken parent { get; set; } + public int? pageSize { get; set; } + public int? cursor { get; set; } + public int? maxNodes { get; set; } + public int? maxDepth { get; set; } + public int? maxChildrenPerNode { get; set; } + public bool? includeTransform { get; set; } } private static SceneCommand ToSceneCommand(JObject p) @@ -40,6 +49,21 @@ private static SceneCommand ToSceneCommand(JObject p) if (double.TryParse(s, out var d)) return (int)d; return t.Type == JTokenType.Integer ? t.Value() : (int?)null; } + bool? BB(JToken t) + { + if (t == null || t.Type == JTokenType.Null) return null; + try + { + if (t.Type == JTokenType.Boolean) return t.Value(); + var s = t.ToString().Trim(); + if (s.Length == 0) return null; + if (bool.TryParse(s, out var b)) return b; + if (s == "1") return true; + if (s == "0") return false; + } + catch { } + return null; + } return new SceneCommand { action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), @@ -47,7 +71,16 @@ private static SceneCommand ToSceneCommand(JObject p) path = p["path"]?.ToString() ?? string.Empty, buildIndex = BI(p["buildIndex"] ?? p["build_index"]), fileName = (p["fileName"] ?? p["filename"])?.ToString() ?? string.Empty, - superSize = BI(p["superSize"] ?? p["super_size"] ?? p["supersize"]) + superSize = BI(p["superSize"] ?? p["super_size"] ?? p["supersize"]), + + // get_hierarchy paging + safety + parent = p["parent"], + pageSize = BI(p["pageSize"] ?? p["page_size"]), + cursor = BI(p["cursor"]), + maxNodes = BI(p["maxNodes"] ?? p["max_nodes"]), + maxDepth = BI(p["maxDepth"] ?? p["max_depth"]), + maxChildrenPerNode = BI(p["maxChildrenPerNode"] ?? p["max_children_per_node"]), + includeTransform = BB(p["includeTransform"] ?? p["include_transform"]), }; } @@ -137,7 +170,7 @@ public static object HandleCommand(JObject @params) return SaveScene(fullPath, relativePath); case "get_hierarchy": try { McpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { } - var gh = GetSceneHierarchy(); + var gh = GetSceneHierarchyPaged(cmd); try { McpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { } return gh; case "get_active": @@ -452,7 +485,7 @@ private static object GetBuildSettingsScenes() } } - private static object GetSceneHierarchy() + private static object GetSceneHierarchyPaged(SceneCommand cmd) { try { @@ -466,15 +499,71 @@ private static object GetSceneHierarchy() ); } - try { McpLog.Info("[ManageScene] get_hierarchy: fetching root objects", always: false); } catch { } - GameObject[] rootObjects = activeScene.GetRootGameObjects(); - try { McpLog.Info($"[ManageScene] get_hierarchy: rootCount={rootObjects?.Length ?? 0}", always: false); } catch { } - var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList(); + // Defaults tuned for safety; callers can override but we clamp to sane maxes. + // NOTE: pageSize is "items per page", not "number of pages". + // Keep this conservative to reduce peak response sizes when callers omit page_size. + int resolvedPageSize = Mathf.Clamp(cmd.pageSize ?? 50, 1, 500); + int resolvedCursor = Mathf.Max(0, cmd.cursor ?? 0); + int resolvedMaxNodes = Mathf.Clamp(cmd.maxNodes ?? 1000, 1, 5000); + int effectiveTake = Mathf.Min(resolvedPageSize, resolvedMaxNodes); + int resolvedMaxChildrenPerNode = Mathf.Clamp(cmd.maxChildrenPerNode ?? 200, 0, 2000); + bool includeTransform = cmd.includeTransform ?? false; - var resp = new SuccessResponse( - $"Retrieved hierarchy for scene '{activeScene.name}'.", - hierarchy - ); + // NOTE: maxDepth is accepted for forward-compatibility, but current paging mode + // returns a single level (roots or direct children). This keeps payloads bounded. + + List nodes; + string scope; + + GameObject parentGo = ResolveGameObject(cmd.parent, activeScene); + if (cmd.parent == null || cmd.parent.Type == JTokenType.Null) + { + try { McpLog.Info("[ManageScene] get_hierarchy: listing root objects (paged summary)", always: false); } catch { } + nodes = activeScene.GetRootGameObjects().Where(go => go != null).ToList(); + scope = "roots"; + } + else + { + if (parentGo == null) + { + return new ErrorResponse($"Parent GameObject ('{cmd.parent}') not found."); + } + try { McpLog.Info($"[ManageScene] get_hierarchy: listing children of '{parentGo.name}' (paged summary)", always: false); } catch { } + nodes = new List(parentGo.transform.childCount); + foreach (Transform child in parentGo.transform) + { + if (child != null) nodes.Add(child.gameObject); + } + scope = "children"; + } + + int total = nodes.Count; + if (resolvedCursor > total) resolvedCursor = total; + int end = Mathf.Min(total, resolvedCursor + effectiveTake); + + var items = new List(Mathf.Max(0, end - resolvedCursor)); + for (int i = resolvedCursor; i < end; i++) + { + var go = nodes[i]; + if (go == null) continue; + items.Add(BuildGameObjectSummary(go, includeTransform, resolvedMaxChildrenPerNode)); + } + + bool truncated = end < total; + string nextCursor = truncated ? end.ToString() : null; + + var payload = new + { + scope = scope, + cursor = resolvedCursor, + pageSize = effectiveTake, + next_cursor = nextCursor, + truncated = truncated, + total = total, + items = items, + }; + + var resp = new SuccessResponse($"Retrieved hierarchy page for scene '{activeScene.name}'.", payload); try { McpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { } return resp; } @@ -485,6 +574,111 @@ private static object GetSceneHierarchy() } } + private static GameObject ResolveGameObject(JToken targetToken, Scene activeScene) + { + if (targetToken == null || targetToken.Type == JTokenType.Null) return null; + + try + { + if (targetToken.Type == JTokenType.Integer || int.TryParse(targetToken.ToString(), out _)) + { + if (int.TryParse(targetToken.ToString(), out int id)) + { + var obj = EditorUtility.InstanceIDToObject(id); + if (obj is GameObject go) return go; + if (obj is Component c) return c.gameObject; + } + } + } + catch { } + + string s = targetToken.ToString(); + if (string.IsNullOrEmpty(s)) return null; + + // Path-based find (e.g., "Root/Child/GrandChild") + if (s.Contains("/")) + { + try { return GameObject.Find(s); } catch { } + } + + // Name-based find (first match, includes inactive) + try + { + var all = activeScene.GetRootGameObjects(); + foreach (var root in all) + { + if (root == null) continue; + if (root.name == s) return root; + var trs = root.GetComponentsInChildren(includeInactive: true); + foreach (var t in trs) + { + if (t != null && t.gameObject != null && t.gameObject.name == s) return t.gameObject; + } + } + } + catch { } + + return null; + } + + private static object BuildGameObjectSummary(GameObject go, bool includeTransform, int maxChildrenPerNode) + { + if (go == null) return null; + + int childCount = 0; + try { childCount = go.transform != null ? go.transform.childCount : 0; } catch { } + bool childrenTruncated = childCount > 0; // We do not inline children in summary mode. + + var d = new Dictionary + { + { "name", go.name }, + { "instanceID", go.GetInstanceID() }, + { "activeSelf", go.activeSelf }, + { "activeInHierarchy", go.activeInHierarchy }, + { "tag", go.tag }, + { "layer", go.layer }, + { "isStatic", go.isStatic }, + { "path", GetGameObjectPath(go) }, + { "childCount", childCount }, + { "childrenTruncated", childrenTruncated }, + { "childrenCursor", childCount > 0 ? "0" : null }, + { "childrenPageSizeDefault", maxChildrenPerNode }, + }; + + if (includeTransform && go.transform != null) + { + var t = go.transform; + d["transform"] = new + { + position = new[] { t.localPosition.x, t.localPosition.y, t.localPosition.z }, + rotation = new[] { t.localRotation.eulerAngles.x, t.localRotation.eulerAngles.y, t.localRotation.eulerAngles.z }, + scale = new[] { t.localScale.x, t.localScale.y, t.localScale.z }, + }; + } + + return d; + } + + private static string GetGameObjectPath(GameObject go) + { + if (go == null) return string.Empty; + try + { + var names = new Stack(); + Transform t = go.transform; + while (t != null) + { + names.Push(t.name); + t = t.parent; + } + return string.Join("/", names); + } + catch + { + return go.name; + } + } + /// /// Recursively builds a data representation of a GameObject and its children. /// diff --git a/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs b/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs index 59a3bb689..c732052eb 100644 --- a/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs @@ -30,6 +30,7 @@ public class McpSettingsSection private TextField gitUrlOverride; private Button browseGitUrlButton; private Button clearGitUrlButton; + private Toggle devModeForceRefreshToggle; private TextField deploySourcePath; private Button browseDeploySourceButton; private Button clearDeploySourceButton; @@ -79,6 +80,7 @@ private void CacheUIElements() gitUrlOverride = Root.Q("git-url-override"); browseGitUrlButton = Root.Q