From 35165e11b3ad6e351c624a58c6dae93b81fb6011 Mon Sep 17 00:00:00 2001 From: dsarno Date: Sun, 28 Dec 2025 20:57:57 -0800 Subject: [PATCH] Payload-safe paging for hierarchy/components + safer asset search + docs (#490) * Fix test teardown to avoid dropping MCP bridge CodexConfigHelperTests was calling MCPServiceLocator.Reset() in TearDown, which disposes the active bridge/transport during MCP-driven test runs. Replace with restoring only the mutated service (IPlatformService). * Avoid leaking PlatformService in CodexConfigHelperTests Capture the original IPlatformService before this fixture runs and restore it in TearDown. This preserves the MCP connection safety fix (no MCPServiceLocator.Reset()) while avoiding global state leakage to subsequent tests. * Fix SO MCP tooling: validate folder roots, normalize paths, expand tests; remove vestigial SO tools * Remove UnityMCPTests stress artifacts and ignore Assets/Temp * Ignore UnityMCPTests Assets/Temp only * Clarify array_resize fallback logic comments * Refactor: simplify action set and reuse slash sanitization * Enhance: preserve GUID on overwrite & support Vector/Color types in ScriptableObject tools * Fix: ensure asset name matches filename to suppress Unity warnings * Fix: resolve Unity warnings by ensuring asset name match and removing redundant import * Refactor: Validate assetName, strict object parsing for vectors, remove broken SO logic from ManageAsset * Hardening: reject Windows drive paths; clarify supported asset types * Delete FixscriptableobjecPlan.md * Paginate get_hierarchy and get_components to prevent large payload crashes * dev: add uvx dev-mode refresh + safer HTTP stop; fix server typing eval * Payload-safe paging defaults + docs; harden asset search; stabilize Codex tests * chore: align uvx args + coercion helpers; tighten safety guidance * chore: minor cleanup + stabilize EditMode SO tests --- .../Clients/McpClientConfiguratorBase.cs | 15 +- .../Editor/Constants/EditorPrefKeys.cs | 1 + .../Editor/Helpers/CodexConfigHelper.cs | 28 ++ .../Editor/Helpers/ConfigJsonBuilder.cs | 23 +- .../Services/ServerManagementService.cs | 159 ++++++-- MCPForUnity/Editor/Tools/ManageGameObject.cs | 166 ++++++-- MCPForUnity/Editor/Tools/ManageScene.cs | 216 +++++++++- .../Components/Settings/McpSettingsSection.cs | 10 + .../Settings/McpSettingsSection.uxml | 7 + Server/pyproject.toml | 2 +- Server/src/main.py | 38 ++ .../services/tools/debug_request_context.py | 9 + Server/src/services/tools/manage_asset.py | 62 +-- .../src/services/tools/manage_gameobject.py | 16 +- Server/src/services/tools/manage_scene.py | 51 ++- Server/src/services/tools/read_console.py | 33 +- Server/src/services/tools/run_tests.py | 19 +- Server/src/services/tools/utils.py | 17 + .../test_debug_request_context_diagnostics.py | 29 ++ .../test_manage_gameobject_param_coercion.py | 40 +- .../test_manage_scene_paging_params.py | 44 +++ .../test_tool_signatures_paging.py | 33 ++ Server/uv.lock | 371 +++++++++++++++++- .../Helpers/CodexConfigHelperTests.cs | 20 + .../EditMode/Tools/ManageGameObjectTests.cs | 76 ++++ .../Tools/ManageSceneHierarchyPagingTests.cs | 105 +++++ .../ManageSceneHierarchyPagingTests.cs.meta | 11 + .../Tools/ManageScriptableObjectTests.cs | 19 + docs/README-DEV.md | 36 ++ 29 files changed, 1459 insertions(+), 197 deletions(-) create mode 100644 Server/tests/integration/test_debug_request_context_diagnostics.py create mode 100644 Server/tests/integration/test_manage_scene_paging_params.py create mode 100644 Server/tests/integration/test_tool_signatures_paging.py create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs.meta diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index f1dc2eb..eb4ba2b 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -423,7 +423,9 @@ namespace MCPForUnity.Editor.Clients 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 @@ namespace MCPForUnity.Editor.Clients } 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 d6edcf1..25542ab 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -20,6 +20,7 @@ namespace MCPForUnity.Editor.Constants 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 75b243b..3a8a6cf 100644 --- a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs +++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs @@ -2,9 +2,11 @@ using System; 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 @@ namespace MCPForUnity.Editor.Helpers { // 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 @@ namespace MCPForUnity.Editor.Helpers { // 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 3c0ba70..067eed9 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -81,7 +81,10 @@ namespace MCPForUnity.Editor.Helpers // 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 @@ namespace MCPForUnity.Editor.Helpers 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 3192753..b081dad 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 @@ namespace MCPForUnity.Editor.Services // 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 @@ namespace MCPForUnity.Editor.Services 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 @@ namespace MCPForUnity.Editor.Services } } - private int GetProcessIdForPort(int port) + private List GetListeningProcessIdsForPort(int port) { + var results = new List(); try { string stdout, stderr; @@ -280,7 +301,7 @@ namespace MCPForUnity.Editor.Services 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 @@ namespace MCPForUnity.Editor.Services } 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 @@ namespace MCPForUnity.Editor.Services { 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 @@ namespace MCPForUnity.Editor.Services { McpLog.Warn($"Error checking port {port}: {ex.Message}"); } - return -1; + return results.Distinct().ToList(); } - private void KillProcess(int pid) + 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 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 @@ namespace MCPForUnity.Editor.Services 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 77f3fde..18497ec 100644 --- a/MCPForUnity/Editor/Tools/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs @@ -180,8 +180,44 @@ namespace MCPForUnity.Editor.Tools 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 @@ namespace MCPForUnity.Editor.Tools 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 @@ namespace MCPForUnity.Editor.Tools 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); - var componentData = new List(); - - for (int i = componentCount - 1; i >= 0; i--) // Iterate backwards over the COPY + // 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) { - Component c = componentsToIterate[i]; // Use the copy - if (c == null) + for (int i = 0; i < all.Length; i++) { - // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] Encountered a null component at index {i} on {targetGo.name}. Skipping."); - continue; // Safety check + if (all[i] != null) components.Add(all[i]); } - // Debug.Log($"[GetComponentsFromTarget REVERSE for] Processing component: {c.GetType()?.FullName ?? "null"} (ID: {c.GetInstanceID()}) at index {i} on {targetGo.name}"); + } + + int total = components.Count; + if (resolvedCursor > total) resolvedCursor = total; + int end = Mathf.Min(total, resolvedCursor + effectiveTake); + + var items = new List(Mathf.Max(0, end - resolvedCursor)); + + // 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++) + { + var c = components[i]; + if (c == null) continue; + + if (!includeProperties) + { + items.Add(BuildComponentMetadata(c)); + continue; + } + 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 @@ namespace MCPForUnity.Editor.Tools } } + 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 eb41a8f..2c10f45 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -26,6 +26,15 @@ namespace MCPForUnity.Editor.Tools 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 @@ namespace MCPForUnity.Editor.Tools 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 @@ namespace MCPForUnity.Editor.Tools 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 @@ namespace MCPForUnity.Editor.Tools 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 @@ namespace MCPForUnity.Editor.Tools } } - private static object GetSceneHierarchy() + private static object GetSceneHierarchyPaged(SceneCommand cmd) { try { @@ -466,15 +499,71 @@ namespace MCPForUnity.Editor.Tools ); } - 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 @@ namespace MCPForUnity.Editor.Tools } } + 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 59a3bb6..c732052 100644 --- a/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs @@ -30,6 +30,7 @@ namespace MCPForUnity.Editor.Windows.Components.Settings 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 @@ namespace MCPForUnity.Editor.Windows.Components.Settings gitUrlOverride = Root.Q("git-url-override"); browseGitUrlButton = Root.Q