From 98083c03fcbef6ac5e785e45428b87f955f0755a Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Sat, 18 Jul 2026 18:05:30 +0200 Subject: [PATCH 01/15] [FIX] ContextRoutes, every /api/context call returned HTTP 500 : GetContextResponse reads EditorPrefs (main-thread-only) but ran on the HTTP ThreadPool thread; both context routes now go through ExecuteOnMainThread like every other sync route (root cause + fix per community PR #17 by rcasaleiro). Verified live: the exact call that 500'd now returns a graceful context payload [FEAT] Security, browser CSRF / DNS-rebinding guard : the bridge can execute arbitrary editor code but accepted any local HTTP request; IsTrustedLocalRequest now rejects non-loopback Host and any non-loopback Origin (403) before touching editor state. Local tools (no Origin) unaffected - verified live: evil Origin 403, evil Host 400/403, MCP servers fine [FEAT] Capabilities, protocol handshake plugin half : ping advertises monotonic protocolVersion (1) + pluginVersion (from PackageInfo); unknown routes return HTTP 404 on the legacy path so servers can degrade gracefully across version drift (re-implementation of community PR #20 by D3vCrow). Verified live [FIX] RouteRegistry, GetRegisteredRoutes had drifted to ~150 of 321 routes with wrong names (breaking dynamic tool discovery) : route list is now GENERATED from the RouteRequest dispatch switch into MCPBridgeServer.Routes.g.cs by tools~/generate-routes.mjs; CI workflow fails on drift (--check). 321 routes, verified superset of every truly-dispatchable old entry [FIX] ExecuteCode, numbers serialized as strings : SerializeResult ToString'd every reflected property and list element (42 became "42"); new depth-capped recursive SerializeValue preserves primitive types, recurses dicts/lists/anonymous objects (depth 4, 1000-item cap), and renders UnityEngine.Object graphs compactly. Verified live: int/float/bool/mixed-list all come back typed [FIX] ActionHistory, TargetInstanceId was int and silently truncated 64-bit Unity 6.5 EntityIds : now an opaque string end to end (record, JsonUtility persistence DTO, history window, select-target via MCPObjectId). Old persisted entries lose only this field on first load [FIX] BridgeServer, error responses leaked exception stack traces to the wire : traces now go to the editor log only [FIX] MacOS, ExecuteCode could not find Roslyn : add Contents/Resources/Scripting to the assembly search paths (community PR #19 by JetNik) --- .github/workflows/checks.yml | 33 +++ Editor/MCPActionHistory.cs | 4 +- Editor/MCPActionHistoryWindow.cs | 8 +- Editor/MCPActionRecord.cs | 23 +- Editor/MCPBridgeServer.Routes.g.cs | 341 ++++++++++++++++++++++++ Editor/MCPBridgeServer.Routes.g.cs.meta | 11 + Editor/MCPBridgeServer.cs | 183 +++++++------ Editor/MCPEditorCommands.cs | 115 +++++--- tools~/generate-routes.mjs | 95 +++++++ 9 files changed, 673 insertions(+), 140 deletions(-) create mode 100644 .github/workflows/checks.yml create mode 100644 Editor/MCPBridgeServer.Routes.g.cs create mode 100644 Editor/MCPBridgeServer.Routes.g.cs.meta create mode 100644 tools~/generate-routes.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..55b0643 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,33 @@ +name: Checks + +on: + push: + branches: ["**"] + paths: + - "Editor/**" + - "tools~/**" + - ".github/workflows/checks.yml" + pull_request: + paths: + - "Editor/**" + - "tools~/**" + - ".github/workflows/checks.yml" + +concurrency: + group: checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + route-registry: + name: route registry drift check + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - name: Verify MCPBridgeServer.Routes.g.cs matches the dispatch switch + run: node tools~/generate-routes.mjs --check diff --git a/Editor/MCPActionHistory.cs b/Editor/MCPActionHistory.cs index 6e81770..5f35a00 100644 --- a/Editor/MCPActionHistory.cs +++ b/Editor/MCPActionHistory.cs @@ -208,7 +208,7 @@ private static void SaveToDisk() status = r.Status ?? "", executionTimeMs = r.ExecutionTimeMs, errorMessage = r.ErrorMessage ?? "", - targetInstanceId = r.TargetInstanceId, + targetInstanceId = r.TargetInstanceId ?? "", targetPath = r.TargetPath ?? "", targetType = r.TargetType ?? "", undoGroup = r.UndoGroup, @@ -289,7 +289,7 @@ private class HistoryEntry public string status; public long executionTimeMs; public string errorMessage; - public int targetInstanceId; + public string targetInstanceId; // string since 64-bit EntityId support; old int-typed persisted entries lose only this field on first load public string targetPath; public string targetType; public int undoGroup; diff --git a/Editor/MCPActionHistoryWindow.cs b/Editor/MCPActionHistoryWindow.cs index 1bc1d83..de54739 100644 --- a/Editor/MCPActionHistoryWindow.cs +++ b/Editor/MCPActionHistoryWindow.cs @@ -431,7 +431,7 @@ private void DrawDetailPanel() } // Select button - if (r.TargetInstanceId != 0) + if (!string.IsNullOrEmpty(r.TargetInstanceId)) { if (GUILayout.Button("Select", EditorStyles.miniButton, GUILayout.Width(45))) SelectTargetObject(r); @@ -453,8 +453,8 @@ private void DrawDetailPanel() DrawDetailRow("Target", r.TargetPath); if (!string.IsNullOrEmpty(r.TargetType)) DrawDetailRow("Target Type", r.TargetType); - if (r.TargetInstanceId != 0) - DrawDetailRow("Instance ID", r.TargetInstanceId.ToString()); + if (!string.IsNullOrEmpty(r.TargetInstanceId)) + DrawDetailRow("Instance ID", r.TargetInstanceId); if (r.UndoGroup >= 0) DrawDetailRow("Undo Group", r.UndoGroup.ToString()); @@ -508,7 +508,7 @@ private void DrawDetailRow(string label, string value, Color? valueColor = null) private void SelectTargetObject(MCPActionRecord record) { - if (record.TargetInstanceId != 0) + if (!string.IsNullOrEmpty(record.TargetInstanceId)) { var obj = MCPObjectId.ToObject(record.TargetInstanceId); if (obj != null) diff --git a/Editor/MCPActionRecord.cs b/Editor/MCPActionRecord.cs index e86ce37..c9227e1 100644 --- a/Editor/MCPActionRecord.cs +++ b/Editor/MCPActionRecord.cs @@ -21,8 +21,10 @@ public class MCPActionRecord public long ExecutionTimeMs { get; set; } public string ErrorMessage { get; set; } - // Target object tracking - public int TargetInstanceId { get; set; } // 0 = no target + // Target object tracking. String because Unity 6.5 EntityIds are 64-bit + // values carried as opaque decimal strings on the wire (see MCPObjectId) — + // an int here silently truncated them. null/empty = no target. + public string TargetInstanceId { get; set; } public string TargetPath { get; set; } public string TargetType { get; set; } // GameObject, Component, Asset, Script, Scene, etc. @@ -62,15 +64,12 @@ public void ExtractTargetFromResult(object result) { if (!(result is Dictionary dict)) return; - // Instance ID - if (dict.TryGetValue("instanceId", out var idObj)) + // Instance ID — stored verbatim as a string (lossless for 64-bit EntityIds) + if (dict.TryGetValue("instanceId", out var idObj) && idObj != null) { - if (idObj is int intId) - TargetInstanceId = intId; - else if (idObj is long longId) - TargetInstanceId = (int)longId; - else if (int.TryParse(idObj?.ToString(), out int parsed)) - TargetInstanceId = parsed; + string id = idObj.ToString(); + if (!string.IsNullOrEmpty(id)) + TargetInstanceId = id; } // Path @@ -124,7 +123,7 @@ public string ToCopyString() if (!string.IsNullOrEmpty(TargetPath)) sb.AppendLine($"Target: {TargetPath}"); - if (TargetInstanceId != 0) + if (!string.IsNullOrEmpty(TargetInstanceId)) sb.AppendLine($"InstanceId: {TargetInstanceId}"); if (!string.IsNullOrEmpty(ErrorMessage)) sb.AppendLine($"Error: {ErrorMessage}"); @@ -154,7 +153,7 @@ public Dictionary ToDict() { "status", Status ?? "" }, { "executionTimeMs", ExecutionTimeMs }, { "errorMessage", ErrorMessage ?? "" }, - { "targetInstanceId", TargetInstanceId }, + { "targetInstanceId", TargetInstanceId ?? "" }, { "targetPath", TargetPath ?? "" }, { "targetType", TargetType ?? "" }, { "undoGroup", UndoGroup }, diff --git a/Editor/MCPBridgeServer.Routes.g.cs b/Editor/MCPBridgeServer.Routes.g.cs new file mode 100644 index 0000000..8828d5b --- /dev/null +++ b/Editor/MCPBridgeServer.Routes.g.cs @@ -0,0 +1,341 @@ +// +// Generated by tools~/generate-routes.mjs from the RouteRequest dispatch in +// MCPBridgeServer.cs — DO NOT EDIT BY HAND. Regenerate after adding a route: +// node tools~/generate-routes.mjs +// CI runs --check mode and fails when this file is out of date. +using System.Collections.Generic; + +namespace UnityMCP.Editor +{ + public static partial class MCPBridgeServer + { + /// Every route the bridge can dispatch (321 routes). + internal static readonly string[] GeneratedRoutes = new string[] + { + "_meta/routes", + "agents/list", + "agents/log", + "amplify/add-node", + "amplify/close", + "amplify/connect", + "amplify/create-from-template", + "amplify/create-shader", + "amplify/disconnect", + "amplify/disconnect-all", + "amplify/duplicate-node", + "amplify/focus-node", + "amplify/get-connections", + "amplify/get-node-types", + "amplify/get-nodes", + "amplify/info", + "amplify/list", + "amplify/list-functions", + "amplify/master-node-info", + "amplify/move-node", + "amplify/node-info", + "amplify/open", + "amplify/remove-node", + "amplify/save", + "amplify/set-node-property", + "amplify/status", + "animation/add-event", + "animation/add-keyframe", + "animation/add-layer", + "animation/add-parameter", + "animation/add-state", + "animation/add-transition", + "animation/assign-controller", + "animation/clip-info", + "animation/controller-info", + "animation/create-blend-tree", + "animation/create-clip", + "animation/create-controller", + "animation/get-blend-tree", + "animation/get-curve-keyframes", + "animation/get-events", + "animation/remove-curve", + "animation/remove-event", + "animation/remove-keyframe", + "animation/remove-layer", + "animation/remove-parameter", + "animation/remove-state", + "animation/remove-transition", + "animation/set-clip-curve", + "animation/set-clip-settings", + "asmdef/add-references", + "asmdef/create", + "asmdef/create-ref", + "asmdef/info", + "asmdef/list", + "asmdef/remove-references", + "asmdef/set-platforms", + "asmdef/update-settings", + "asset/create-material", + "asset/create-prefab", + "asset/delete", + "asset/import", + "asset/instantiate-prefab", + "asset/list", + "audio/create-source", + "audio/info", + "audio/set-global", + "build/start", + "compilation/errors", + "component/add", + "component/batch-wire", + "component/get-properties", + "component/get-referenceable", + "component/remove", + "component/set-property", + "component/set-reference", + "console/clear", + "console/log", + "constraint/add", + "constraint/info", + "debugger/enable", + "debugger/event-details", + "debugger/events", + "editor/execute-code", + "editor/execute-menu-item", + "editor/play-mode", + "editor/state", + "editorprefs/delete", + "editorprefs/get", + "editorprefs/set", + "gameobject/create", + "gameobject/delete", + "gameobject/info", + "gameobject/set-transform", + "graphics/asset-preview", + "graphics/game-capture", + "graphics/lighting-summary", + "graphics/material-info", + "graphics/mesh-info", + "graphics/prefab-render", + "graphics/renderer-info", + "graphics/scene-capture", + "graphics/texture-info", + "input/add-action", + "input/add-binding", + "input/add-composite-binding", + "input/add-map", + "input/create", + "input/info", + "input/remove-action", + "input/remove-map", + "lighting/create", + "lighting/create-light-probe-group", + "lighting/create-reflection-probe", + "lighting/info", + "lighting/set-environment", + "lod/create", + "lod/info", + "mppm/activate-player", + "mppm/deactivate-player", + "mppm/list-players", + "navigation/add-agent", + "navigation/add-obstacle", + "navigation/bake", + "navigation/clear", + "navigation/info", + "navigation/set-destination", + "packages/add", + "packages/info", + "packages/list", + "packages/remove", + "packages/search", + "particle/create", + "particle/info", + "particle/playback", + "particle/set-emission", + "particle/set-main", + "particle/set-shape", + "physics/collision-matrix", + "physics/overlap-box", + "physics/overlap-sphere", + "physics/raycast", + "physics/set-collision-layer", + "physics/set-gravity", + "ping", + "playerprefs/delete", + "playerprefs/delete-all", + "playerprefs/get", + "playerprefs/set", + "prefab-asset/add-component", + "prefab-asset/add-gameobject", + "prefab-asset/apply-variant-override", + "prefab-asset/compare-variant", + "prefab-asset/get-properties", + "prefab-asset/hierarchy", + "prefab-asset/remove-component", + "prefab-asset/remove-gameobject", + "prefab-asset/revert-variant-override", + "prefab-asset/set-property", + "prefab-asset/set-reference", + "prefab-asset/transfer-variant-overrides", + "prefab-asset/variant-info", + "prefab/apply-overrides", + "prefab/create-variant", + "prefab/duplicate", + "prefab/info", + "prefab/reparent", + "prefab/revert-overrides", + "prefab/set-active", + "prefab/set-object-reference", + "prefab/unpack", + "profiler/analyze", + "profiler/enable", + "profiler/frame-data", + "profiler/memory", + "profiler/memory-breakdown", + "profiler/memory-snapshot", + "profiler/memory-status", + "profiler/memory-top-assets", + "profiler/stats", + "project/info", + "renderer/set-material", + "scenario/activate", + "scenario/create", + "scenario/info", + "scenario/list", + "scenario/start", + "scenario/status", + "scenario/stop", + "scene/hierarchy", + "scene/info", + "scene/new", + "scene/open", + "scene/save", + "sceneview/info", + "sceneview/set-camera", + "screenshot/editor-window", + "screenshot/game", + "screenshot/scene", + "script/create", + "script/read", + "script/update", + "scriptableobject/create", + "scriptableobject/info", + "scriptableobject/list-types", + "scriptableobject/set-field", + "search/assets", + "search/by-component", + "search/by-layer", + "search/by-name", + "search/by-shader", + "search/by-tag", + "search/missing-references", + "search/scene-stats", + "selection/find-by-type", + "selection/focus-scene-view", + "selection/get", + "selection/set", + "settings/physics", + "settings/player", + "settings/quality", + "settings/quality-level", + "settings/render-pipeline", + "settings/set-physics", + "settings/set-player", + "settings/set-time", + "settings/time", + "shadergraph/add-node", + "shadergraph/connect", + "shadergraph/create", + "shadergraph/disconnect", + "shadergraph/get-edges", + "shadergraph/get-node-types", + "shadergraph/get-nodes", + "shadergraph/get-properties", + "shadergraph/info", + "shadergraph/list", + "shadergraph/list-shaders", + "shadergraph/list-subgraphs", + "shadergraph/list-vfx", + "shadergraph/open", + "shadergraph/open-vfx", + "shadergraph/remove-node", + "shadergraph/set-node-property", + "shadergraph/status", + "spriteatlas/add", + "spriteatlas/create", + "spriteatlas/delete", + "spriteatlas/info", + "spriteatlas/list", + "spriteatlas/remove", + "spriteatlas/settings", + "taglayer/add-tag", + "taglayer/info", + "taglayer/set-layer", + "taglayer/set-static", + "taglayer/set-tag", + "terrain/add-detail-prototype", + "terrain/add-layer", + "terrain/add-tree-prototype", + "terrain/clear-detail", + "terrain/clear-trees", + "terrain/create", + "terrain/create-grid", + "terrain/export-heightmap", + "terrain/fill-layer", + "terrain/flatten", + "terrain/get-height", + "terrain/get-heights-region", + "terrain/get-steepness", + "terrain/get-tree-instances", + "terrain/import-heightmap", + "terrain/info", + "terrain/list", + "terrain/noise", + "terrain/paint-detail", + "terrain/paint-layer", + "terrain/place-trees", + "terrain/raise-lower", + "terrain/remove-layer", + "terrain/remove-tree-prototype", + "terrain/resize", + "terrain/scatter-detail", + "terrain/set-height", + "terrain/set-heights-region", + "terrain/set-holes", + "terrain/set-neighbors", + "terrain/set-settings", + "terrain/smooth", + "testing/get-job", + "testing/run-tests", + "texture/info", + "texture/reimport", + "texture/set-import", + "texture/set-normalmap", + "texture/set-sprite", + "ui/create-canvas", + "ui/create-element", + "ui/info", + "ui/set-image", + "ui/set-text", + "uma/create-overlay", + "uma/create-race", + "uma/create-slot", + "uma/create-wardrobe-from-fbx", + "uma/create-wardrobe-recipe", + "uma/edit-race", + "uma/get-project-config", + "uma/inspect-fbx", + "uma/list-global-library", + "uma/list-uma-materials", + "uma/list-wardrobe-slots", + "uma/rebuild-global-library", + "uma/register-assets", + "uma/rename-asset", + "uma/verify-recipe", + "uma/wardrobe-equip", + "undo/clear", + "undo/history", + "undo/perform", + "undo/redo", + }; + + /// Fast membership lookup used for unknown-route 404s. + internal static readonly HashSet KnownRoutes = new HashSet(GeneratedRoutes); + } +} diff --git a/Editor/MCPBridgeServer.Routes.g.cs.meta b/Editor/MCPBridgeServer.Routes.g.cs.meta new file mode 100644 index 0000000..562c9d9 --- /dev/null +++ b/Editor/MCPBridgeServer.Routes.g.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ee5ba3ed730a49bd914a9991093ac86a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MCPBridgeServer.cs b/Editor/MCPBridgeServer.cs index 3c597ba..f08795e 100644 --- a/Editor/MCPBridgeServer.cs +++ b/Editor/MCPBridgeServer.cs @@ -20,7 +20,7 @@ namespace UnityMCP.Editor /// Both modes go through MCPRequestQueue for fair round-robin scheduling. /// [InitializeOnLoad] - public static class MCPBridgeServer + public static partial class MCPBridgeServer { private static HttpListener _listener; private static Thread _listenerThread; @@ -46,6 +46,26 @@ private static readonly Dictionary, Ac { "testing/list-tests", MCPTestRunnerCommands.ListTests }, }; + // ─── Capability handshake (unity-mcp-server PRs #32/#20) ─── + // One monotonic int, bumped whenever the bridge gains a wire-visible capability. + // Servers compare it to decide between fast paths and graceful fallbacks. + // v1: baseline — advertises the handshake itself + unknown-route 404s. + private const int ProtocolVersion = 1; + + private static string _pluginVersion; + private static string PluginVersion + { + get + { + if (_pluginVersion == null) + { + var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(MCPBridgeServer).Assembly); + _pluginVersion = info != null ? info.version : "unknown"; + } + return _pluginVersion; + } + } + // SessionState key to persist running state across domain reloads (Play Mode, recompile) private const string WasRunningKey = "UnityMCP_WasRunningBeforeReload"; @@ -303,6 +323,38 @@ private static void ListenLoop() // ─── Request Handler ─── + /// + /// True when the request comes from a local non-browser client: loopback Host + /// (or none) and no cross-site Origin. Browser pages always attach their page + /// Origin on cross-origin fetches — the vehicle CSRF and DNS-rebinding ride on. + /// A "null" Origin (file:// pages, sandboxed frames) is rejected too. + /// + private static bool IsTrustedLocalRequest(HttpListenerRequest request) + { + string host = request.Headers["Host"]; + if (!string.IsNullOrEmpty(host)) + { + string hostName = host; + int colon = host.LastIndexOf(':'); + if (colon >= 0 && host.IndexOf(']') < colon) hostName = host.Substring(0, colon); + hostName = hostName.Trim('[', ']'); + if (hostName != "127.0.0.1" && hostName != "localhost" && hostName != "::1") + return false; + } + + string origin = request.Headers["Origin"]; + if (!string.IsNullOrEmpty(origin)) + { + bool loopbackOrigin = + origin.StartsWith("http://127.0.0.1") || origin.StartsWith("http://localhost") || + origin.StartsWith("https://127.0.0.1") || origin.StartsWith("https://localhost"); + if (!loopbackOrigin) + return false; + } + + return true; + } + private static void HandleRequest(HttpListenerContext context) { var request = context.Request; @@ -310,6 +362,18 @@ private static void HandleRequest(HttpListenerContext context) try { + // ─── Cross-origin / DNS-rebinding guard ─── + // The bridge can execute arbitrary editor code, so only same-machine + // tools may talk to it. Legit clients (Node MCP server, curl) send a + // loopback Host and no Origin header; a browser page doing a CSRF/ + // DNS-rebinding fetch always attaches its page Origin (or a non-loopback + // Host), which we reject before touching any editor state. + if (!IsTrustedLocalRequest(request)) + { + SendJson(response, 403, new { error = "Forbidden: only local, non-browser clients may call the MCP bridge" }); + return; + } + string path = request.Url.AbsolutePath.TrimStart('/'); if (!path.StartsWith("api/")) { @@ -345,15 +409,18 @@ private static void HandleRequest(HttpListenerContext context) } // ═══ Project Context endpoints (read-only, no queue needed) ═══ + // Must run on the main thread: GetContextResponse reads EditorPrefs + // (main-thread-only), which made every context call throw HTTP 500 + // from this ThreadPool thread (community PR #17 by rcasaleiro). if (apiPath == "context") { - SendJson(response, 200, MCPContextManager.GetContextResponse()); + SendJson(response, 200, ExecuteOnMainThread(() => MCPContextManager.GetContextResponse())); return; } if (apiPath.StartsWith("context/")) { string category = apiPath.Substring("context/".Length); - SendJson(response, 200, MCPContextManager.GetContextResponse(category)); + SendJson(response, 200, ExecuteOnMainThread(() => MCPContextManager.GetContextResponse(category))); return; } @@ -367,6 +434,16 @@ private static void HandleRequest(HttpListenerContext context) return; } + // ═══ Unknown routes: 404 before dispatch (capability handshake) ═══ + // Lets servers distinguish "this plugin doesn't have that feature" + // from a failed call and degrade gracefully. KnownRoutes is generated + // from the dispatch switch (tools~/generate-routes.mjs, CI-checked). + if (!KnownRoutes.Contains(apiPath)) + { + SendJson(response, 404, new { error = $"Unknown route: {apiPath}" }); + return; + } + // ═══ Legacy synchronous path (blocks until main thread processes) ═══ { var result = MCPRequestQueue.ExecuteWithTracking(agentId, apiPath, @@ -376,7 +453,9 @@ private static void HandleRequest(HttpListenerContext context) } catch (Exception ex) { - SendJson(response, 500, new { error = ex.Message, stackTrace = ex.StackTrace }); + // Full stack trace goes to the editor log only — never to the wire. + Debug.LogError($"[AB-UMCP] Request failed: {ex.Message}\n{ex.StackTrace}"); + SendJson(response, 500, new { error = ex.Message }); } } @@ -463,87 +542,12 @@ private static string ExtractCategory(string path) /// private static object GetRegisteredRoutes() { - // We collect routes by reflecting on the switch cases in RouteRequest. - // Since C# doesn't easily let us introspect switch cases at runtime, - // we maintain a static list of all registered route prefixes/categories. - var routes = new List - { - "ping", - "editor/state", "editor/play-mode", "editor/execute-menu-item", "editor/undo", "editor/redo", "editor/undo-history", - "scene/info", "scene/open", "scene/save", "scene/new", "scene/hierarchy", "scene/stats", - "gameobject/create", "gameobject/delete", "gameobject/info", "gameobject/set-transform", - "gameobject/duplicate", "gameobject/set-active", "gameobject/reparent", - "component/add", "component/remove", "component/get-properties", "component/set-property", - "component/set-reference", "component/batch-wire", "component/get-referenceable", - "asset/list", "asset/import", "asset/delete", "asset/create-prefab", "asset/instantiate-prefab", - "script/create", "script/read", "script/update", "script/execute-code", - "material/create", "material/set-material", - "build/build", "build/play-mode", - "console/log", "console/clear", - "compilation/errors", - "selection/get", "selection/set", "selection/focus-scene-view", "selection/find-by-type", - "search/by-component", "search/by-tag", "search/by-layer", "search/by-name", - "search/assets", "search/missing-references", - "screenshot/game", "screenshot/scene", - "prefab/info", "prefab/set-object-reference", - "packages/list", "packages/add", "packages/remove", "packages/search", "packages/info", - "project/info", - // Animation - "animation/create-controller", "animation/get-controller", "animation/add-state", - "animation/remove-state", "animation/add-transition", "animation/remove-transition", - "animation/set-parameter", "animation/remove-parameter", "animation/get-parameters", - "animation/create-clip", "animation/set-clip-curve", "animation/get-clip-info", - "animation/set-state-motion", "animation/add-layer", "animation/remove-layer", - "animation/get-layers", "animation/set-default-state", "animation/add-blend-tree", - // Physics - "physics/raycast", "physics/overlap-sphere", "physics/settings", - "physics/add-joint", "physics/get-joint", "physics/set-joint", - // Audio - "audio/play", "audio/stop", "audio/get-info", "audio/set-property", - // UI - "ui/create-canvas", "ui/add-element", "ui/set-rect", "ui/set-text", - "ui/set-image", "ui/set-button", "ui/get-hierarchy", - // Lighting - "lighting/create", "lighting/set-property", "lighting/bake", "lighting/get-settings", - "lighting/set-settings", "lighting/get-probes", - // NavMesh - "navmesh/bake", "navmesh/add-agent", "navmesh/set-area", "navmesh/get-info", - "navmesh/add-obstacle", "navmesh/add-link", - // ShaderGraph - "shadergraph/create", "shadergraph/get-info", "shadergraph/add-node", - "shadergraph/remove-node", "shadergraph/connect", "shadergraph/disconnect", - "shadergraph/set-property", "shadergraph/list-nodes", "shadergraph/get-connections", - // Amplify - "amplify/list", "amplify/info", "amplify/open", "amplify/list-functions", - "amplify/get-node-types", "amplify/get-nodes", "amplify/get-connections", - "amplify/create-shader", "amplify/add-node", "amplify/remove-node", - "amplify/connect", "amplify/disconnect", "amplify/node-info", - "amplify/set-node-property", "amplify/move-node", - // Graphics - "graphics/camera-info", "graphics/render-settings", "graphics/set-render-settings", - "graphics/texture-info", "graphics/renderer-info", "graphics/lighting-summary", - // Terrain - "terrain/create", "terrain/info", "terrain/set-height", "terrain/flatten", - "terrain/add-layer", "terrain/get-height", "terrain/list", - "terrain/raise-lower", "terrain/smooth", "terrain/noise", - "terrain/set-heights-region", "terrain/get-heights-region", - "terrain/remove-layer", "terrain/paint-layer", "terrain/fill-layer", - "terrain/add-tree-prototype", "terrain/remove-tree-prototype", - "terrain/place-trees", "terrain/clear-trees", "terrain/get-tree-instances", - "terrain/add-detail-prototype", "terrain/paint-detail", - "terrain/scatter-detail", "terrain/clear-detail", - "terrain/set-holes", "terrain/set-settings", "terrain/resize", - "terrain/create-grid", "terrain/set-neighbors", - "terrain/import-heightmap", "terrain/export-heightmap", "terrain/get-steepness", - // Particle System - "particle/create", "particle/info", "particle/set-main", "particle/set-emission", - "particle/set-shape", "particle/set-velocity", "particle/set-color", - "particle/set-size", "particle/set-renderer", - }; - - // Group by category + // The route list is GENERATED from the RouteRequest dispatch switch by + // tools~/generate-routes.mjs into MCPBridgeServer.Routes.g.cs (CI-checked). + // The previous hand-maintained list had drifted to ~150 of ~320 routes + // with several wrong names, silently breaking dynamic tool discovery. var grouped = new Dictionary>(); - foreach (var route in routes) + foreach (var route in GeneratedRoutes) { string cat = ExtractCategory(route); if (!grouped.ContainsKey(cat)) grouped[cat] = new List(); @@ -552,9 +556,9 @@ private static object GetRegisteredRoutes() return new Dictionary { - { "routes", routes }, + { "routes", GeneratedRoutes }, { "categories", grouped }, - { "totalRoutes", routes.Count } + { "totalRoutes", GeneratedRoutes.Length } }; } @@ -592,7 +596,12 @@ private static object RouteRequest(string path, string method, string body) platform = Application.platform.ToString(), isClone = MCPInstanceRegistry.IsParrelSyncClone(), cloneIndex = MCPInstanceRegistry.GetParrelSyncCloneIndex(), - processId = System.Diagnostics.Process.GetCurrentProcess().Id + processId = System.Diagnostics.Process.GetCurrentProcess().Id, + // Capability handshake: servers gate newer wire features on this + // monotonic int so the pair degrades gracefully across version + // drift (server and plugin ship on separate release trains). + protocolVersion = ProtocolVersion, + pluginVersion = PluginVersion }; // ─── Editor State ─── diff --git a/Editor/MCPEditorCommands.cs b/Editor/MCPEditorCommands.cs index 562b9d0..2d80be4 100755 --- a/Editor/MCPEditorCommands.cs +++ b/Editor/MCPEditorCommands.cs @@ -121,6 +121,9 @@ private static bool TryLoadRoslyn() searchDirs.Add(Path.Combine(data, "Tools", "BuildPipeline", "Compilation", "ApiUpdater")); // ScriptUpdater also ships Roslyn (Mono-compatible) searchDirs.Add(Path.Combine(data, "Tools", "ScriptUpdater")); + // macOS: recent editors ship Roslyn under Contents/Resources/Scripting + // (community PR #19 by JetNik — ExecuteCode was broken on macOS without it) + searchDirs.Add(Path.Combine(data, "Resources", "Scripting")); // DotNetSdkRoslyn contains .NET Core assemblies — may fail on Mono, tried last searchDirs.Add(Path.Combine(data, "DotNetSdkRoslyn")); } @@ -456,60 +459,102 @@ private static object SerializeResult(object result) if (result == null) return new { success = true, result = (object)null }; - // Primitives and strings - if (result is string || result is int || result is float || result is double - || result is bool || result is long || result is decimal) - return new Dictionary { { "success", true }, { "result", result } }; - - // Unity Vector types - if (result is Vector2 v2) - return new Dictionary { { "success", true }, { "result", new { x = v2.x, y = v2.y } } }; - if (result is Vector3 v3) - return new Dictionary { { "success", true }, { "result", new { x = v3.x, y = v3.y, z = v3.z } } }; - if (result is Color col) - return new Dictionary { { "success", true }, { "result", new { r = col.r, g = col.g, b = col.b, a = col.a } } }; - - // Dictionaries - if (result is System.Collections.IDictionary dict) - return new Dictionary { { "success", true }, { "result", result } }; - - // Lists and arrays - serialize elements - if (result is System.Collections.IList list) + object serialized = SerializeValue(result, 0); + + // Preserve the historical { result, count } shape for top-level lists. + if (result is System.Collections.IList && serialized is List items) + return new Dictionary { { "success", true }, { "result", items }, { "count", items.Count } }; + + if (serialized is string str && !(result is string)) + { + // Opaque object fell back to ToString — keep the type name like before. + return new Dictionary + { + { "success", true }, + { "result", str }, + { "type", result.GetType().Name }, + }; + } + + return new Dictionary { { "success", true }, { "result", serialized } }; + } + + private const int MaxSerializeDepth = 4; + private const int MaxSerializeItems = 1000; + + /// + /// Recursively serialize a value, PRESERVING primitive types. The previous + /// implementation ToString'd every list element and reflected property, so + /// numbers came back as strings ("307" instead of 307) and nested objects + /// flattened to type names. Depth/item caps keep pathological returns bounded. + /// + private static object SerializeValue(object value, int depth) + { + if (value == null) return null; + + if (value is string || value is bool + || value is int || value is long || value is short || value is byte + || value is float || value is double || value is decimal + || value is uint || value is ulong || value is ushort || value is sbyte) + return value; + + if (value is Vector2 v2) + return new Dictionary { { "x", v2.x }, { "y", v2.y } }; + if (value is Vector3 v3) + return new Dictionary { { "x", v3.x }, { "y", v3.y }, { "z", v3.z } }; + if (value is Color col) + return new Dictionary { { "r", col.r }, { "g", col.g }, { "b", col.b }, { "a", col.a } }; + + if (depth >= MaxSerializeDepth) + return value.ToString(); + + if (value is System.Collections.IDictionary dictionary) + { + var obj = new Dictionary(); + foreach (System.Collections.DictionaryEntry entry in dictionary) + obj[entry.Key != null ? entry.Key.ToString() : "null"] = SerializeValue(entry.Value, depth + 1); + return obj; + } + + if (value is System.Collections.IEnumerable enumerable) { var items = new List(); - foreach (var item in list) - items.Add(item?.ToString()); - return new Dictionary { { "success", true }, { "result", items }, { "count", items.Count } }; + foreach (var item in enumerable) + { + if (items.Count >= MaxSerializeItems) + { + items.Add($"... (truncated at {MaxSerializeItems} items)"); + break; + } + items.Add(SerializeValue(item, depth + 1)); + } + return items; } - // Anonymous types and complex objects - serialize via reflection - var type = result.GetType(); - if (type.Name.Contains("AnonymousType") || type.IsClass) + var type = value.GetType(); + // UnityEngine.Object graphs (GameObject, Component, ...) are cyclic and + // property access can throw — represent them compactly as ToString. + if (!typeof(UnityEngine.Object).IsAssignableFrom(type) + && (type.Name.Contains("AnonymousType") || type.IsClass)) { try { var props = type.GetProperties(); - if (props.Length > 0) + if (props.Length > 0 && props.Length <= 64) { var obj = new Dictionary(); foreach (var prop in props) { - try { obj[prop.Name] = prop.GetValue(result)?.ToString(); } + try { obj[prop.Name] = SerializeValue(prop.GetValue(value), depth + 1); } catch { obj[prop.Name] = ""; } } - return new Dictionary { { "success", true }, { "result", obj } }; + return obj; } } catch { } } - // Fallback: ToString - return new Dictionary - { - { "success", true }, - { "result", result.ToString() }, - { "type", type.Name }, - }; + return value.ToString(); } } } diff --git a/tools~/generate-routes.mjs b/tools~/generate-routes.mjs new file mode 100644 index 0000000..38bdf72 --- /dev/null +++ b/tools~/generate-routes.mjs @@ -0,0 +1,95 @@ +// Route registry generator — derives the plugin's route list from the actual +// dispatch code in Editor/MCPBridgeServer.cs and writes it to +// Editor/MCPBridgeServer.Routes.g.cs (a partial-class file owned by this script). +// +// The old hand-maintained list in GetRegisteredRoutes() drifted badly (~150 of +// ~320 routes, several wrong names) which silently broke dynamic tool discovery. +// A generated set can't drift: CI runs `node tools~/generate-routes.mjs --check` +// and fails the build when the .g.cs no longer matches the switch. +// +// Usage: +// node tools~/generate-routes.mjs # regenerate the .g.cs file +// node tools~/generate-routes.mjs --check # exit 1 if the file is out of date + +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const SOURCE = join(ROOT, "Editor", "MCPBridgeServer.cs"); +const TARGET = join(ROOT, "Editor", "MCPBridgeServer.Routes.g.cs"); + +const source = readFileSync(SOURCE, "utf8"); + +// ── Extract case labels from the RouteRequest switch ── +const routeRequestStart = source.indexOf("private static object RouteRequest"); +if (routeRequestStart < 0) throw new Error("RouteRequest method not found — did MCPBridgeServer.cs change shape?"); +const routeRequestEnd = source.indexOf("\n default:", routeRequestStart); +if (routeRequestEnd < 0) throw new Error("RouteRequest default case not found"); +const switchBody = source.slice(routeRequestStart, routeRequestEnd); + +const routes = new Set(); +for (const match of switchBody.matchAll(/^\s*case "([^"]+)":/gm)) { + routes.add(match[1]); +} + +// ── Routes dispatched before/outside the switch ── +routes.add("_meta/routes"); + +// ── Deferred routes (async-callback dictionary in the class header) ── +const deferredBlockMatch = source.match(/_deferredRoutes = new Dictionary<[^>]+>>\s*\{([\s\S]*?)\};/); +if (deferredBlockMatch) { + for (const match of deferredBlockMatch[1].matchAll(/\{\s*"([^"]+)"\s*,/g)) { + routes.add(match[1]); + } +} + +if (routes.size < 200) { + throw new Error(`Only ${routes.size} routes extracted — parser likely broke; refusing to emit a shrunken registry.`); +} + +const sorted = [...routes].sort(); +const lines = sorted.map((r) => ` "${r}",`).join("\n"); + +const content = `// +// Generated by tools~/generate-routes.mjs from the RouteRequest dispatch in +// MCPBridgeServer.cs — DO NOT EDIT BY HAND. Regenerate after adding a route: +// node tools~/generate-routes.mjs +// CI runs --check mode and fails when this file is out of date. +using System.Collections.Generic; + +namespace UnityMCP.Editor +{ + public static partial class MCPBridgeServer + { + /// Every route the bridge can dispatch (${sorted.length} routes). + internal static readonly string[] GeneratedRoutes = new string[] + { +${lines} + }; + + /// Fast membership lookup used for unknown-route 404s. + internal static readonly HashSet KnownRoutes = new HashSet(GeneratedRoutes); + } +} +`; + +const checkMode = process.argv.includes("--check"); +let existing = null; +try { + existing = readFileSync(TARGET, "utf8"); +} catch { + // Target doesn't exist yet. +} + +if (checkMode) { + if (existing === content) { + console.log(`Route registry up to date (${sorted.length} routes).`); + process.exit(0); + } + console.error("Route registry is OUT OF DATE. Run: node tools~/generate-routes.mjs"); + process.exit(1); +} + +writeFileSync(TARGET, content, "utf8"); +console.log(`Wrote ${TARGET} with ${sorted.length} routes.`); From 516802ab909099ea819d92146bdfb3a06ff450d4 Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Sat, 18 Jul 2026 18:16:05 +0200 Subject: [PATCH 02/15] [CLEAN] Release : CHANGELOG 2.33.0 (full branch ledger with community credits), version bump 2.32.0 -> 2.33.0 --- CHANGELOG.md | 14 ++++++++++++++ package.json | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a83d262..ae91bef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this package will be documented in this file. +## [2.33.0] - 2026-07-18 + +### Fixed +- **`/api/context` always returned HTTP 500** — `GetContextResponse` reads `EditorPrefs` (main-thread-only) but ran on the HTTP ThreadPool thread, so every Project Context call threw; both context routes now run through `ExecuteOnMainThread` like every other synchronous route. Root cause + fix per PR [#17](https://github.com/AnkleBreaker-Studio/unity-mcp-plugin/pull/17) by @rcasaleiro. +- **`GetRegisteredRoutes` drift** — the hand-maintained route list had drifted to ~150 of 321 routes with several wrong names (e.g. `editor/undo` vs the real `undo/perform`), silently breaking the server's dynamic tool discovery. The list is now **generated from the dispatch switch** into `MCPBridgeServer.Routes.g.cs` by `tools~/generate-routes.mjs`; a CI workflow fails on drift. +- **`editor/execute-code` returned numbers as strings** — result serialization ToString'd every reflected property and list element (`42` became `"42"`, nested objects flattened to type names). New depth-capped recursive serializer preserves primitive types, recurses dicts/lists/anonymous objects (depth 4, 1000-item cap), and renders `UnityEngine.Object` graphs compactly instead of exploding their property trees. +- **Action history truncated 64-bit ids** — `MCPActionRecord.TargetInstanceId` was `int` but Unity 6.5 EntityIds are 64-bit opaque strings; the field is now a string end to end (record, persistence DTO, history window, select-target). Old persisted entries lose only this field on first load. +- **Error responses leaked exception stack traces to the wire** — traces now go to the editor log only. +- **macOS: ExecuteCode could not find Roslyn** — added `Contents/Resources/Scripting` to the assembly search paths, per PR [#19](https://github.com/AnkleBreaker-Studio/unity-mcp-plugin/pull/19) by @JetNik. + +### Added +- **Browser CSRF / DNS-rebinding guard** — the bridge can execute arbitrary editor code but accepted any local HTTP request; requests with a non-loopback `Host` or any non-loopback `Origin` (browser pages always attach one on cross-origin fetches) are now rejected with 403 before touching editor state. Local tools (no Origin header) are unaffected. +- **Capability handshake (plugin half)** — `/api/ping` advertises a monotonic `protocolVersion` (1) and `pluginVersion` (from PackageInfo); unknown routes return HTTP 404 on the legacy path so servers can distinguish "feature missing" from "call failed" and degrade gracefully across version drift. Pairs with `unity-mcp-server` 2.31.0. Re-implementation of PR [#20](https://github.com/AnkleBreaker-Studio/unity-mcp-plugin/pull/20) by @D3vCrow. + ## [2.32.0] - 2026-06-02 ### Added diff --git a/package.json b/package.json index 357f532..f845496 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.anklebreaker.unity-mcp", - "version": "2.32.0", + "version": "2.33.0", "displayName": "AnkleBreaker Unity MCP", "description": "MCP (Model Context Protocol) bridge plugin for Unity Editor. Enables AI assistants like Claude to control Unity Editor via a local HTTP API — manage scenes, GameObjects, components, assets, builds, and more.", "unity": "2021.3", From 1a909c556b700bfa5106d5f075fd6edcdde6fcc3 Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Sat, 18 Jul 2026 18:41:24 +0200 Subject: [PATCH 03/15] [FIX] Security, Origin allowlist used unanchored StartsWith - the anti-CSRF/DNS-rebind guard was bypassable (http://localhost.evil.com passed), re-opening browser-reachable arbitrary editor code execution : Origin is now parsed and its host matched EXACTLY via Uri.TryCreate (malformed/"null" Origin rejected). Verified live: evil-Origin 403, loopback-Origin 200, no-Origin 200 [FIX] Security, stack traces still reached the wire via the main-thread execution path (200 responses) : ExecuteOnMainThread/ExecuteOnMainThreadDeferred error returns now log the trace to the editor and send {error: message} only - completing the "traces off the wire" change on the common route path [FIX] RouteGen, deferred-route extraction was dead (a <[^>]+>> type matcher can't span the nested generic Dictionary>>, silently captured nothing) so testing/list-tests was absent from the registry : match from field name to the initializer brace, throw if zero deferred routes extracted. Registry 321->322, testing/list-tests present (verified live in _meta/routes) [FIX] TrustGuard, Host check was case-sensitive and mis-parsed bare unbracketed IPv6 : shared IsLoopbackHostName (OrdinalIgnoreCase) + bare-::1 guard [CLEAN] Repo : .gitattributes pins the generated .g.cs to LF so a CRLF regenerate can't spuriously fail the CI drift --check; honest comments on the int->string history-field mismatch (JsonUtility parses to "") and on conditional-compilation routes always listing in the generated set --- .gitattributes | 1 + Editor/MCPActionHistory.cs | 2 +- Editor/MCPActionRecord.cs | 3 ++- Editor/MCPBridgeServer.Routes.g.cs | 3 ++- Editor/MCPBridgeServer.cs | 40 ++++++++++++++++++++++++------ tools~/generate-routes.mjs | 25 +++++++++++++++---- 6 files changed, 58 insertions(+), 16 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ede57d4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +Editor/MCPBridgeServer.Routes.g.cs text eol=lf diff --git a/Editor/MCPActionHistory.cs b/Editor/MCPActionHistory.cs index 5f35a00..f63ea53 100644 --- a/Editor/MCPActionHistory.cs +++ b/Editor/MCPActionHistory.cs @@ -289,7 +289,7 @@ private class HistoryEntry public string status; public long executionTimeMs; public string errorMessage; - public string targetInstanceId; // string since 64-bit EntityId support; old int-typed persisted entries lose only this field on first load + public string targetInstanceId; // string since 64-bit EntityId support. JsonUtility tolerates the old int→string scalar mismatch (parses to ""); LoadFromDisk is try/catch-guarded so a hard failure would drop the whole history file, not one field. public string targetPath; public string targetType; public int undoGroup; diff --git a/Editor/MCPActionRecord.cs b/Editor/MCPActionRecord.cs index c9227e1..a722cc7 100644 --- a/Editor/MCPActionRecord.cs +++ b/Editor/MCPActionRecord.cs @@ -23,7 +23,8 @@ public class MCPActionRecord // Target object tracking. String because Unity 6.5 EntityIds are 64-bit // values carried as opaque decimal strings on the wire (see MCPObjectId) — - // an int here silently truncated them. null/empty = no target. + // an int here silently truncated them. null/empty = no target. Old int-typed + // persisted entries: JsonUtility parses the scalar-type mismatch to "". public string TargetInstanceId { get; set; } public string TargetPath { get; set; } public string TargetType { get; set; } // GameObject, Component, Asset, Script, Scene, etc. diff --git a/Editor/MCPBridgeServer.Routes.g.cs b/Editor/MCPBridgeServer.Routes.g.cs index 8828d5b..297b052 100644 --- a/Editor/MCPBridgeServer.Routes.g.cs +++ b/Editor/MCPBridgeServer.Routes.g.cs @@ -9,7 +9,7 @@ namespace UnityMCP.Editor { public static partial class MCPBridgeServer { - /// Every route the bridge can dispatch (321 routes). + /// Every route the bridge can dispatch (322 routes). internal static readonly string[] GeneratedRoutes = new string[] { "_meta/routes", @@ -302,6 +302,7 @@ public static partial class MCPBridgeServer "terrain/set-settings", "terrain/smooth", "testing/get-job", + "testing/list-tests", "testing/run-tests", "texture/info", "texture/reimport", diff --git a/Editor/MCPBridgeServer.cs b/Editor/MCPBridgeServer.cs index f08795e..413c616 100644 --- a/Editor/MCPBridgeServer.cs +++ b/Editor/MCPBridgeServer.cs @@ -336,25 +336,41 @@ private static bool IsTrustedLocalRequest(HttpListenerRequest request) { string hostName = host; int colon = host.LastIndexOf(':'); - if (colon >= 0 && host.IndexOf(']') < colon) hostName = host.Substring(0, colon); + bool bracketed = host.IndexOf(']') >= 0; + // Bare unbracketed IPv6 ("::1") has multiple colons and no brackets — + // don't treat its last colon as a port separator. + bool bareIpv6 = !bracketed && host.IndexOf(':') != colon; + if (!bareIpv6 && colon >= 0 && host.IndexOf(']') < colon) + hostName = host.Substring(0, colon); hostName = hostName.Trim('[', ']'); - if (hostName != "127.0.0.1" && hostName != "localhost" && hostName != "::1") + if (!IsLoopbackHostName(hostName)) return false; } string origin = request.Headers["Origin"]; if (!string.IsNullOrEmpty(origin)) { - bool loopbackOrigin = - origin.StartsWith("http://127.0.0.1") || origin.StartsWith("http://localhost") || - origin.StartsWith("https://127.0.0.1") || origin.StartsWith("https://localhost"); - if (!loopbackOrigin) + // Parse and match the origin host EXACTLY. A StartsWith("http://localhost") + // prefix check would let http://localhost.evil.com straight through — and + // with execute-code behind this guard that is a browser-reachable RCE. + // Uri.TryCreate also rejects the literal "null" Origin (file:// pages, + // sandboxed frames), which we deliberately do not trust. + if (!Uri.TryCreate(origin, UriKind.Absolute, out var originUri)) + return false; + if (!IsLoopbackHostName(originUri.Host.Trim('[', ']'))) return false; } return true; } + private static bool IsLoopbackHostName(string hostName) + { + return hostName == "127.0.0.1" + || hostName == "::1" + || string.Equals(hostName, "localhost", StringComparison.OrdinalIgnoreCase); + } + private static void HandleRequest(HttpListenerContext context) { var request = context.Request; @@ -1389,7 +1405,11 @@ private static object ExecuteOnMainThread(Func action) return new { error = $"Timeout waiting for Unity main thread after {MCPRequestQueue.SyncTimeoutMs / 1000}s" }; if (exception != null) - return new { error = exception.Message, stackTrace = exception.StackTrace }; + { + // Trace goes to the editor log only — never to the wire. + Debug.LogError($"[AB-UMCP] Main-thread execution failed: {exception.Message}\n{exception.StackTrace}"); + return new { error = exception.Message }; + } return result; } @@ -1430,7 +1450,11 @@ private static object ExecuteOnMainThreadDeferred(Action> asyncAc return new { error = $"Timeout waiting for Unity callback after {MCPRequestQueue.SyncTimeoutMs / 1000}s" }; if (exception != null) - return new { error = exception.Message, stackTrace = exception.StackTrace }; + { + // Trace goes to the editor log only — never to the wire. + Debug.LogError($"[AB-UMCP] Deferred execution failed: {exception.Message}\n{exception.StackTrace}"); + return new { error = exception.Message }; + } return result; } diff --git a/tools~/generate-routes.mjs b/tools~/generate-routes.mjs index 38bdf72..7292430 100644 --- a/tools~/generate-routes.mjs +++ b/tools~/generate-routes.mjs @@ -37,12 +37,27 @@ for (const match of switchBody.matchAll(/^\s*case "([^"]+)":/gm)) { routes.add("_meta/routes"); // ── Deferred routes (async-callback dictionary in the class header) ── -const deferredBlockMatch = source.match(/_deferredRoutes = new Dictionary<[^>]+>>\s*\{([\s\S]*?)\};/); -if (deferredBlockMatch) { - for (const match of deferredBlockMatch[1].matchAll(/\{\s*"([^"]+)"\s*,/g)) { - routes.add(match[1]); - } +// Match from the field name to the first initializer brace: the nested generic type +// contains no braces, so the first "{" after "_deferredRoutes" opens the initializer. +// (A "<[^>]+>>" type matcher cannot span Dictionary>> — it dies +// on the first ">" — and silently captured nothing.) +const deferredBlockMatch = source.match(/_deferredRoutes[^{]*\{([\s\S]*?)\};/); +if (!deferredBlockMatch) { + throw new Error("_deferredRoutes initializer not found — parser assumptions broke."); +} +let deferredCount = 0; +for (const match of deferredBlockMatch[1].matchAll(/\{\s*"([^"]+)"\s*,/g)) { + routes.add(match[1]); + deferredCount++; } +if (deferredCount === 0) { + throw new Error("No deferred routes extracted — parser assumptions broke."); +} + +// Note: routes behind conditional compilation (e.g. uma/* under #if UMA_INSTALLED) +// are always listed — source-text generation can't evaluate defines. On projects +// without the optional package those routes pass the 404 gate and fall through to +// the dispatch default's "Unknown API endpoint" error, same as before this registry. if (routes.size < 200) { throw new Error(`Only ${routes.size} routes extracted — parser likely broke; refusing to emit a shrunken registry.`); From 942ae3ad72b0f8679fea3daf02ce7715ff421f1e Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Sat, 18 Jul 2026 19:57:33 +0200 Subject: [PATCH 04/15] [FEAT] AssetSafety : shared MCPAssetSafety helper (correct project-root resolution via GetDirectoryName(dataPath), path canonicalization + traversal/absolute-escape guard, existence-based overwrite guard). Live-verified: traversal ../../evil.cs and absolute C:/evil.cs both rejected [FIX] ScriptCommands, three source-code-loss vectors : (1) dataPath.Replace("/Assets","") stripped EVERY occurrence -> wrong root for projects under a /Assets path -> writes landed outside the project (import silently no-op); now GetDirectoryName(dataPath). (2) Update with missing/empty content truncated the target source file to 0 bytes -> content now required. (3) Create silently overwrote existing scripts -> overwrite guard. (4) raw path traversal/absolute escape -> confined under project root. Live-verified all four [FIX] CreateAsset overwrite destroys existing assets (systemic) : added the existence guard to ScriptableObject, Terrain CreateTerrain, Animation controller + clip, Material, and asset Import (CreateAsset/File.Copy reuse the GUID and wipe the existing asset + every reference). SpriteAtlas already did this; now consistent. overwrite:true opts in [FIX] Queue double-execution : a ticket whose 30s sync waiter already gave up (TimedOut) stayed in the queue and still executed later -> a client retry ran the non-idempotent action twice. ProcessNextRequests now drops TimedOut tickets before executing (race-safe under _queueLock) [FIX] SetProperty silent no-op reported success : Color/Vector2/3/4/Rect branches did nothing when the value wasn't an object (agent passing "1,0,0,1" or an array) yet returned success; now they throw a clear ArgumentException the handler surfaces as an error [FIX] asmdef RemoveReferences removed the wrong reference : Contains substring match (removing Unity.InputSystem also matched Unity.InputSystem.ForUI, breaking compilation while reporting success) -> exact name/GUID match only --- Editor/MCPAnimationCommands.cs | 10 +++ Editor/MCPAssemblyDefCommands.cs | 6 +- Editor/MCPAssetCommands.cs | 20 ++++- Editor/MCPAssetSafety.cs | 113 ++++++++++++++++++++++++++ Editor/MCPAssetSafety.cs.meta | 11 +++ Editor/MCPComponentCommands.cs | 68 ++++++++++------ Editor/MCPRequestQueue.cs | 15 ++++ Editor/MCPScriptCommands.cs | 35 ++++---- Editor/MCPScriptableObjectCommands.cs | 6 ++ Editor/MCPTerrainCommands.cs | 5 ++ 10 files changed, 244 insertions(+), 45 deletions(-) create mode 100644 Editor/MCPAssetSafety.cs create mode 100644 Editor/MCPAssetSafety.cs.meta diff --git a/Editor/MCPAnimationCommands.cs b/Editor/MCPAnimationCommands.cs index 225d880..cce002b 100644 --- a/Editor/MCPAnimationCommands.cs +++ b/Editor/MCPAnimationCommands.cs @@ -36,6 +36,11 @@ public static object CreateController(Dictionary args) } } + // Don't silently replace an existing controller (all states/transitions lost). + var controllerOverwrite = MCPAssetSafety.OverwriteGuard(path, args); + if (controllerOverwrite != null) + return controllerOverwrite; + var controller = AnimatorController.CreateAnimatorControllerAtPath(path); if (controller == null) return new { error = "Failed to create animator controller" }; @@ -387,6 +392,11 @@ public static object CreateClip(Dictionary args) } } + // Don't silently wipe an existing clip's curves. + var clipOverwrite = MCPAssetSafety.OverwriteGuard(path, args); + if (clipOverwrite != null) + return clipOverwrite; + var clip = new AnimationClip(); clip.name = Path.GetFileNameWithoutExtension(path); diff --git a/Editor/MCPAssemblyDefCommands.cs b/Editor/MCPAssemblyDefCommands.cs index db3f49a..7546dda 100644 --- a/Editor/MCPAssemblyDefCommands.cs +++ b/Editor/MCPAssemblyDefCommands.cs @@ -251,10 +251,12 @@ public static object RemoveReferences(Dictionary args) var removed = new List(); foreach (string refToRemove in refsToRemove) { - // Try to match by name or GUID + // Match by EXACT name or GUID only. A substring Contains match used to remove + // the wrong reference (removing "Unity.InputSystem" also matched + // "Unity.InputSystem.ForUI"), breaking compilation while reporting success. string match = existingRefs.FirstOrDefault(r => r.Equals(refToRemove, StringComparison.OrdinalIgnoreCase) || - r.Contains(refToRemove)); + r.Equals("GUID:" + refToRemove, StringComparison.OrdinalIgnoreCase)); if (match != null) { diff --git a/Editor/MCPAssetCommands.cs b/Editor/MCPAssetCommands.cs index 8a9d002..fd6b22c 100755 --- a/Editor/MCPAssetCommands.cs +++ b/Editor/MCPAssetCommands.cs @@ -71,13 +71,24 @@ public static object Import(Dictionary args) if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(dest)) return new { error = "sourcePath and destinationPath are required" }; - string fullDest = Path.Combine(Application.dataPath.Replace("/Assets", ""), dest); + if (!File.Exists(source)) + return new { error = $"Source file not found: {source}" }; + + // Confine the destination under the project (correct root, no traversal escape). + if (!MCPAssetSafety.TryResolveProjectPath(dest, out string fullDest, out string pathError)) + return new { error = pathError }; + + // Don't silently overwrite an existing project asset unless asked. + var overwriteError = MCPAssetSafety.OverwriteGuard(dest, args); + if (overwriteError != null) + return overwriteError; + string destDir = Path.GetDirectoryName(fullDest); if (!Directory.Exists(destDir)) Directory.CreateDirectory(destDir); File.Copy(source, fullDest, true); - AssetDatabase.ImportAsset(dest); + AssetDatabase.ImportAsset(MCPAssetSafety.ToAssetDatabasePath(dest)); return new { success = true, importedPath = dest }; } @@ -176,6 +187,11 @@ public static object CreateMaterial(Dictionary args) var shader = Shader.Find(shaderName); if (shader == null) return new { error = $"Shader '{shaderName}' not found" }; + // Don't reset an existing tuned material back to defaults. + var materialOverwrite = MCPAssetSafety.OverwriteGuard(path, args); + if (materialOverwrite != null) + return materialOverwrite; + var material = new Material(shader); if (args.ContainsKey("color")) diff --git a/Editor/MCPAssetSafety.cs b/Editor/MCPAssetSafety.cs new file mode 100644 index 0000000..89fff9f --- /dev/null +++ b/Editor/MCPAssetSafety.cs @@ -0,0 +1,113 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace UnityMCP.Editor +{ + /// + /// Shared guards for asset-writing MCP handlers. Two recurring data-loss classes + /// are centralized here: + /// 1. Path resolution — the project root is the folder CONTAINING Assets/, i.e. + /// Path.GetDirectoryName(Application.dataPath). The old idiom + /// dataPath.Replace("/Assets","") stripped EVERY "/Assets" occurrence, so a + /// project under a path containing "/Assets" resolved to the wrong root and + /// writes landed outside the project (import silently no-ops). Paths are also + /// canonicalized and confined under the project root so "../" or an absolute + /// path can't escape and clobber arbitrary files on disk. + /// 2. Overwrite — CreateAsset/File.WriteAllText on an existing asset destroys the + /// user's asset (and, for CreateAsset, every reference to it since the GUID is + /// reused). Callers gate on AssetWouldOverwrite unless the caller passes overwrite:true. + /// + internal static class MCPAssetSafety + { + /// Absolute path of the folder containing Assets/ (and Packages/, ProjectSettings/). + internal static string ProjectRoot => Path.GetDirectoryName(Application.dataPath); + + /// + /// Resolve a project-relative asset path (e.g. "Assets/Scripts/X.cs") to an absolute + /// path, confined under the project root. Returns false with a message if the path is + /// empty, absolute, or escapes the project via "..". + /// + internal static bool TryResolveProjectPath(string assetPath, out string fullPath, out string error) + { + fullPath = null; + error = null; + + if (string.IsNullOrEmpty(assetPath)) + { + error = "path is required"; + return false; + } + + // Reject rooted/absolute inputs outright: Path.Combine returns the second + // argument verbatim when it is rooted, which would escape the project. + if (Path.IsPathRooted(assetPath)) + { + error = $"path must be project-relative (under Assets/ or Packages/), got absolute: {assetPath}"; + return false; + } + + string root = ProjectRoot; + string combined; + try + { + combined = Path.GetFullPath(Path.Combine(root, assetPath)); + } + catch (Exception ex) + { + error = $"invalid path '{assetPath}': {ex.Message}"; + return false; + } + + string rootFull = Path.GetFullPath(root); + string rootPrefix = rootFull.EndsWith(Path.DirectorySeparatorChar.ToString()) + ? rootFull + : rootFull + Path.DirectorySeparatorChar; + + // Case-insensitive on Windows/macOS default filesystems; ordinal is the safe superset. + if (!combined.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) + { + error = $"path escapes the project root: {assetPath}"; + return false; + } + + fullPath = combined; + return true; + } + + /// Project-relative asset path normalized to forward slashes (for AssetDatabase APIs). + internal static string ToAssetDatabasePath(string assetPath) + { + return assetPath.Replace('\\', '/'); + } + + /// True if an asset already exists at this project path (AssetDatabase view). + internal static bool AssetWouldOverwrite(string assetPath) + { + string dbPath = ToAssetDatabasePath(assetPath); + return AssetDatabase.LoadMainAssetAtPath(dbPath) != null; + } + + /// + /// Standard overwrite guard for asset creators. Returns an error object to return + /// directly, or null if creation may proceed. Pass the caller's args so a caller can + /// opt in with overwrite:true. + /// + internal static object OverwriteGuard(string assetPath, System.Collections.Generic.Dictionary args) + { + bool overwrite = args != null && args.ContainsKey("overwrite") + && args["overwrite"] != null + && (args["overwrite"].ToString().ToLowerInvariant() == "true" || args["overwrite"].ToString() == "1"); + if (!overwrite && AssetWouldOverwrite(assetPath)) + { + return new + { + error = $"An asset already exists at '{ToAssetDatabasePath(assetPath)}'. Pass overwrite:true to replace it (this destroys the existing asset and its references).", + existingAsset = ToAssetDatabasePath(assetPath), + }; + } + return null; + } + } +} diff --git a/Editor/MCPAssetSafety.cs.meta b/Editor/MCPAssetSafety.cs.meta new file mode 100644 index 0000000..e3453db --- /dev/null +++ b/Editor/MCPAssetSafety.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b4e535f372394edd98d200cef31ef0f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MCPComponentCommands.cs b/Editor/MCPComponentCommands.cs index 0d55e64..7a73a12 100755 --- a/Editor/MCPComponentCommands.cs +++ b/Editor/MCPComponentCommands.cs @@ -575,6 +575,15 @@ internal static object GetSerializedValue(SerializedProperty prop) } } + /// Short human description of a value's shape for error messages. + private static string DescribeValue(object value) + { + if (value == null) return "null"; + if (value is string s) return $"string \"{s}\""; + if (value is System.Collections.IList) return "an array"; + return $"{value.GetType().Name} ({value})"; + } + internal static void SetSerializedValue(SerializedProperty prop, object value) { switch (prop.propertyType) @@ -593,36 +602,40 @@ internal static void SetSerializedValue(SerializedProperty prop, object value) break; case SerializedPropertyType.Color: var cd = value as Dictionary; - if (cd != null) - prop.colorValue = new Color( - Convert.ToSingle(cd.GetValueOrDefault("r", 0f)), - Convert.ToSingle(cd.GetValueOrDefault("g", 0f)), - Convert.ToSingle(cd.GetValueOrDefault("b", 0f)), - Convert.ToSingle(cd.GetValueOrDefault("a", 1f))); + if (cd == null) + throw new ArgumentException($"Color property '{prop.name}' expects an object {{r,g,b,a}}, got {DescribeValue(value)}."); + prop.colorValue = new Color( + Convert.ToSingle(cd.GetValueOrDefault("r", 0f)), + Convert.ToSingle(cd.GetValueOrDefault("g", 0f)), + Convert.ToSingle(cd.GetValueOrDefault("b", 0f)), + Convert.ToSingle(cd.GetValueOrDefault("a", 1f))); break; case SerializedPropertyType.Vector2: var v2d = value as Dictionary; - if (v2d != null) - prop.vector2Value = new Vector2( - Convert.ToSingle(v2d.GetValueOrDefault("x", 0f)), - Convert.ToSingle(v2d.GetValueOrDefault("y", 0f))); + if (v2d == null) + throw new ArgumentException($"Vector2 property '{prop.name}' expects an object {{x,y}}, got {DescribeValue(value)}."); + prop.vector2Value = new Vector2( + Convert.ToSingle(v2d.GetValueOrDefault("x", 0f)), + Convert.ToSingle(v2d.GetValueOrDefault("y", 0f))); break; case SerializedPropertyType.Vector3: var vd = value as Dictionary; - if (vd != null) - prop.vector3Value = new Vector3( - Convert.ToSingle(vd.GetValueOrDefault("x", 0f)), - Convert.ToSingle(vd.GetValueOrDefault("y", 0f)), - Convert.ToSingle(vd.GetValueOrDefault("z", 0f))); + if (vd == null) + throw new ArgumentException($"Vector3 property '{prop.name}' expects an object {{x,y,z}}, got {DescribeValue(value)}."); + prop.vector3Value = new Vector3( + Convert.ToSingle(vd.GetValueOrDefault("x", 0f)), + Convert.ToSingle(vd.GetValueOrDefault("y", 0f)), + Convert.ToSingle(vd.GetValueOrDefault("z", 0f))); break; case SerializedPropertyType.Vector4: var v4d = value as Dictionary; - if (v4d != null) - prop.vector4Value = new Vector4( - Convert.ToSingle(v4d.GetValueOrDefault("x", 0f)), - Convert.ToSingle(v4d.GetValueOrDefault("y", 0f)), - Convert.ToSingle(v4d.GetValueOrDefault("z", 0f)), - Convert.ToSingle(v4d.GetValueOrDefault("w", 0f))); + if (v4d == null) + throw new ArgumentException($"Vector4 property '{prop.name}' expects an object {{x,y,z,w}}, got {DescribeValue(value)}."); + prop.vector4Value = new Vector4( + Convert.ToSingle(v4d.GetValueOrDefault("x", 0f)), + Convert.ToSingle(v4d.GetValueOrDefault("y", 0f)), + Convert.ToSingle(v4d.GetValueOrDefault("z", 0f)), + Convert.ToSingle(v4d.GetValueOrDefault("w", 0f))); break; case SerializedPropertyType.Enum: if (value is string enumName) @@ -640,12 +653,13 @@ internal static void SetSerializedValue(SerializedProperty prop, object value) break; case SerializedPropertyType.Rect: var rd = value as Dictionary; - if (rd != null) - prop.rectValue = new Rect( - Convert.ToSingle(rd.GetValueOrDefault("x", 0f)), - Convert.ToSingle(rd.GetValueOrDefault("y", 0f)), - Convert.ToSingle(rd.GetValueOrDefault("width", 0f)), - Convert.ToSingle(rd.GetValueOrDefault("height", 0f))); + if (rd == null) + throw new ArgumentException($"Rect property '{prop.name}' expects an object {{x,y,width,height}}, got {DescribeValue(value)}."); + prop.rectValue = new Rect( + Convert.ToSingle(rd.GetValueOrDefault("x", 0f)), + Convert.ToSingle(rd.GetValueOrDefault("y", 0f)), + Convert.ToSingle(rd.GetValueOrDefault("width", 0f)), + Convert.ToSingle(rd.GetValueOrDefault("height", 0f))); break; case SerializedPropertyType.ObjectReference: prop.objectReferenceValue = ResolveObjectReference(value); diff --git a/Editor/MCPRequestQueue.cs b/Editor/MCPRequestQueue.cs index fd420bf..f3543e0 100644 --- a/Editor/MCPRequestQueue.cs +++ b/Editor/MCPRequestQueue.cs @@ -256,6 +256,21 @@ public static void ProcessNextRequests() batch = DequeueNextBatch(); if (batch == null || batch.Count == 0) return; + // Drop tickets whose sync waiter already gave up (TimedOut). The client was + // told the call failed and may have retried; executing the abandoned ticket + // now would run a non-idempotent action a second time. ExecuteWithTracking + // sets TimedOut under this same lock, so this check is race-safe. + batch.RemoveAll(t => + { + if (t.Status == RequestStatus.TimedOut) + { + _executingTickets.Remove(t.TicketId); + return true; + } + return false; + }); + if (batch.Count == 0) return; + // Mark all as executing and track in-flight foreach (var t in batch) { diff --git a/Editor/MCPScriptCommands.cs b/Editor/MCPScriptCommands.cs index 6a94d5f..97c5df0 100755 --- a/Editor/MCPScriptCommands.cs +++ b/Editor/MCPScriptCommands.cs @@ -12,19 +12,24 @@ public static object Create(Dictionary args) string path = args.ContainsKey("path") ? args["path"].ToString() : ""; string content = args.ContainsKey("content") ? args["content"].ToString() : ""; - if (string.IsNullOrEmpty(path)) - return new { error = "path is required" }; if (string.IsNullOrEmpty(content)) return new { error = "content is required" }; - string fullPath = Path.Combine(Application.dataPath.Replace("/Assets", ""), path); - string dir = Path.GetDirectoryName(fullPath); + // Resolve under the project root and reject traversal/absolute escapes. + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new { error = pathError }; + + // Never silently overwrite an existing script (source-code loss). + var overwriteError = MCPAssetSafety.OverwriteGuard(path, args); + if (overwriteError != null) + return overwriteError; + string dir = Path.GetDirectoryName(fullPath); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); File.WriteAllText(fullPath, content); - AssetDatabase.ImportAsset(path); + AssetDatabase.ImportAsset(MCPAssetSafety.ToAssetDatabasePath(path)); return new { success = true, path, size = content.Length }; } @@ -32,10 +37,9 @@ public static object Create(Dictionary args) public static object Read(Dictionary args) { string path = args.ContainsKey("path") ? args["path"].ToString() : ""; - if (string.IsNullOrEmpty(path)) - return new { error = "path is required" }; - string fullPath = Path.Combine(Application.dataPath.Replace("/Assets", ""), path); + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new { error = pathError }; if (!File.Exists(fullPath)) return new { error = $"File not found: {path}" }; @@ -53,18 +57,21 @@ public static object Read(Dictionary args) public static object Update(Dictionary args) { string path = args.ContainsKey("path") ? args["path"].ToString() : ""; - string content = args.ContainsKey("content") ? args["content"].ToString() : ""; - if (string.IsNullOrEmpty(path)) - return new { error = "path is required" }; + // Update REQUIRES content: an unvalidated empty content used to truncate the + // target source file to zero bytes and import the wreckage. + if (!args.ContainsKey("content") || args["content"] == null) + return new { error = "content is required" }; + string content = args["content"].ToString(); - string fullPath = Path.Combine(Application.dataPath.Replace("/Assets", ""), path); + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new { error = pathError }; if (!File.Exists(fullPath)) - return new { error = $"File not found: {path}" }; + return new { error = $"File not found: {path}. Use script/create for a new file." }; File.WriteAllText(fullPath, content); - AssetDatabase.ImportAsset(path); + AssetDatabase.ImportAsset(MCPAssetSafety.ToAssetDatabasePath(path)); return new { success = true, path, size = content.Length }; } diff --git a/Editor/MCPScriptableObjectCommands.cs b/Editor/MCPScriptableObjectCommands.cs index cf15af3..0d9d1dc 100644 --- a/Editor/MCPScriptableObjectCommands.cs +++ b/Editor/MCPScriptableObjectCommands.cs @@ -55,6 +55,12 @@ public static object CreateScriptableObject(Dictionary args) } } + // Never silently replace an existing asset (CreateAsset reuses the GUID and + // destroys the old asset plus every reference to it). + var overwriteError = MCPAssetSafety.OverwriteGuard(path, args); + if (overwriteError != null) + return overwriteError; + var so = ScriptableObject.CreateInstance(soType); AssetDatabase.CreateAsset(so, path); AssetDatabase.SaveAssets(); diff --git a/Editor/MCPTerrainCommands.cs b/Editor/MCPTerrainCommands.cs index 9636f17..175b054 100644 --- a/Editor/MCPTerrainCommands.cs +++ b/Editor/MCPTerrainCommands.cs @@ -30,6 +30,11 @@ public static object CreateTerrain(Dictionary args) string dataPath = args.ContainsKey("dataPath") ? args["dataPath"].ToString() : $"Assets/{name}_Data.asset"; + // Re-running with the same name/dataPath must not blow away an existing terrain's + // sculpted heightmap/splats (CreateAsset reuses the GUID). + var overwriteError = MCPAssetSafety.OverwriteGuard(dataPath, args); + if (overwriteError != null) + return overwriteError; EnsureDirectoryExists(dataPath); AssetDatabase.CreateAsset(terrainData, dataPath); From b63d719e63e6c5b3cbedc5ba6cfa20810a3fc9be Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Sat, 18 Jul 2026 20:13:16 +0200 Subject: [PATCH 05/15] [FIX] ShaderGraph, all four asset-corruption bugs (issue #18) via the real GraphData API : - New MCPShaderGraphApi: reflection wrapper over ShaderGraph's real editor model (GraphData / MultiJson / FileUtilities). create/add-node/connect/disconnect now build the graph through the actual model and let ShaderGraph serialize it, so the output is ALWAYS a valid, importable asset (the old regex/JSON string-surgery produced invalid graphs) - BUG 1 create ignored template -> identical 809-byte INVALID graph (no target, dangling m_OutputNode, fails import): now builds a real URP Lit/Unlit graph (target + default surface/vertex blocks) via new GraphData + AddContexts + InitializeOutputs + AddRemoveBlocksFromActiveList; 'blank' = valid empty graph. Live: urp_lit 15KB/9 blocks compiles - BUG 3 add-node PropertyNode/empty-slot nodes broke import (NRE): nodes are instantiated through the model with their real slots; graph compiles - BUG 4 add-node ignored x/y: position set on the node's DrawState (struct written back); accepts positionX/positionY and x/y. Live: 150/400 honored - BUG 2 disconnect matched by output slot only (dropped sibling edges) AND blanked surviving multi-line edges to m_Id:"": now removes exactly the tuple-matched edges via graph.RemoveEdge; live proof: 2 edges from one output slot, disconnect one, the sibling SURVIVES with its real id, graph compiles - connect now validates slot compatibility / refuses cycles (graph.Connect) instead of blind edge-JSON insertion - Path-safety: create/add/connect/disconnect resolve under the project root (MCPAssetSafety); create gets an overwrite guard - Removed the superseded dead helpers (GetMinimalShaderGraphJson, GetMenuPathForTemplate, GetNodeTemplate, TrySerializeNodeViaReflection). Read-only JSON parsing helpers (get-nodes/get-edges/remove-node) untouched - Fail-closed: if the ShaderGraph API is unavailable (version drift), handlers return a clear error rather than corrupting anything --- Editor/MCPShaderGraphApi.cs | 272 +++++++++++++++++++ Editor/MCPShaderGraphApi.cs.meta | 11 + Editor/MCPShaderGraphCommands.cs | 434 ++++++------------------------- 3 files changed, 358 insertions(+), 359 deletions(-) create mode 100644 Editor/MCPShaderGraphApi.cs create mode 100644 Editor/MCPShaderGraphApi.cs.meta diff --git a/Editor/MCPShaderGraphApi.cs b/Editor/MCPShaderGraphApi.cs new file mode 100644 index 0000000..62a5d9d --- /dev/null +++ b/Editor/MCPShaderGraphApi.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using UnityEditor; +using UnityEngine; + +namespace UnityMCP.Editor +{ + /// + /// Reflection wrapper over ShaderGraph's REAL editor API (GraphData / MultiJson / + /// FileUtilities). ShaderGraph edits used to be done by regex/string surgery on the + /// .shadergraph JSON, which produced invalid graphs (dangling output node, blanked + /// edges, unbound property nodes → import NRE). Round-tripping through the actual + /// GraphData model guarantees the serialized result is always valid, because + /// ShaderGraph itself writes it — exactly what the editor does on save. + /// + /// All types are internal to Unity.ShaderGraph.Editor / the URP editor assembly, so + /// every access is reflected. Handles are resolved once and cached; Available is false + /// if the ShaderGraph package or an expected member is missing (older/newer versions), + /// and callers fall back to a clear error rather than corrupting anything. + /// + internal static class MCPShaderGraphApi + { + internal static bool Available { get; private set; } + internal static string UnavailableReason { get; private set; } + + private static Type _graphDataT, _targetT, _blockFieldT, _absNodeT, _slotRefT, _materialSlotT, _messageManagerT, _multiJsonT, _fileUtilT, _propertyNodeT; + private static Type _urpTargetT, _litSubT, _unlitSubT, _spriteLitSubT, _spriteUnlitSubT; + private static PropertyInfo _messageManagerProp, _objectIdProp, _drawStateProp, _edgesProp, _drawPositionProp; + private static MethodInfo _addContexts, _initOutputs, _getActiveBlocks, _addRemoveBlocks, _onEnable, _addNode, _getNodeFromId, _connect, _removeEdge, _getNodesGeneric, _getSlotsGeneric, _trySetSubTarget; + private static MethodInfo _serialize, _deserializeGeneric, _writeToDisk, _writeGraphToDisk; + private static ConstructorInfo _slotRefCtor; + + static MCPShaderGraphApi() + { + try { Initialize(); Available = true; } + catch (Exception ex) { Available = false; UnavailableReason = ex.Message; } + } + + private static Type Req(Assembly asm, string fullName) + { + var t = asm.GetType(fullName); + if (t == null) throw new InvalidOperationException($"ShaderGraph type not found: {fullName}"); + return t; + } + + private static Type FindType(Assembly asm, string simpleName) + { + try { return asm.GetTypes().FirstOrDefault(t => t.Name == simpleName); } + catch (ReflectionTypeLoadException e) { return e.Types.FirstOrDefault(t => t != null && t.Name == simpleName); } + } + + private static void Initialize() + { + var sg = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == "Unity.ShaderGraph.Editor") + ?? throw new InvalidOperationException("Unity.ShaderGraph.Editor assembly not loaded"); + var urp = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == "Unity.RenderPipelines.Universal.Editor"); + + _graphDataT = Req(sg, "UnityEditor.ShaderGraph.GraphData"); + _targetT = Req(sg, "UnityEditor.ShaderGraph.Target"); + _blockFieldT = Req(sg, "UnityEditor.ShaderGraph.BlockFieldDescriptor"); + _absNodeT = Req(sg, "UnityEditor.ShaderGraph.AbstractMaterialNode"); + _slotRefT = Req(sg, "UnityEditor.Graphing.SlotReference"); + _materialSlotT = Req(sg, "UnityEditor.ShaderGraph.MaterialSlot"); + _multiJsonT = Req(sg, "UnityEditor.ShaderGraph.Serialization.MultiJson"); + _fileUtilT = Req(sg, "UnityEditor.ShaderGraph.FileUtilities"); + _propertyNodeT = sg.GetType("UnityEditor.ShaderGraph.PropertyNode"); + _messageManagerT = FindType(sg, "MessageManager") ?? throw new InvalidOperationException("MessageManager not found"); + + const BindingFlags PIF = BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic; + const BindingFlags SF = BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic; + + _messageManagerProp = _graphDataT.GetProperty("messageManager", PIF) ?? throw new InvalidOperationException("messageManager"); + _objectIdProp = _absNodeT.GetProperty("objectId", PIF) ?? throw new InvalidOperationException("objectId"); + _drawStateProp = _absNodeT.GetProperty("drawState", PIF) ?? throw new InvalidOperationException("drawState"); + _edgesProp = _graphDataT.GetProperty("edges", PIF) ?? throw new InvalidOperationException("edges"); + _drawPositionProp = _drawStateProp.PropertyType.GetProperty("position", PIF) ?? throw new InvalidOperationException("drawState.position"); + + _addContexts = _graphDataT.GetMethod("AddContexts", PIF); + _initOutputs = _graphDataT.GetMethod("InitializeOutputs", PIF); + _getActiveBlocks = _graphDataT.GetMethod("GetActiveBlocksForAllActiveTargets", PIF); + _addRemoveBlocks = _graphDataT.GetMethod("AddRemoveBlocksFromActiveList", PIF); + _onEnable = _graphDataT.GetMethod("OnEnable", PIF); + _addNode = _graphDataT.GetMethod("AddNode", PIF, null, new[] { _absNodeT }, null); + // GetNodeFromId has both a generic and a non-generic (string) overload, so a + // typed GetMethod is ambiguous — pick the non-generic one explicitly. + _getNodeFromId = _graphDataT.GetMethods(PIF).FirstOrDefault(m => + m.Name == "GetNodeFromId" && !m.IsGenericMethod && + m.GetParameters().Length == 1 && m.GetParameters()[0].ParameterType == typeof(string)); + _connect = _graphDataT.GetMethod("Connect", PIF, null, new[] { _slotRefT, _slotRefT }, null); + _removeEdge = _graphDataT.GetMethods(PIF).FirstOrDefault(m => m.Name == "RemoveEdge" && m.GetParameters().Length == 1); + _getNodesGeneric = _graphDataT.GetMethods(PIF).FirstOrDefault(m => m.Name == "GetNodes" && m.IsGenericMethod); + _getSlotsGeneric = _absNodeT.GetMethods(BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(m => m.Name == "GetSlots" && m.IsGenericMethod && m.GetParameters().Length == 1); + _trySetSubTarget = _urpTargetTryResolve(urp); + _slotRefCtor = _slotRefT.GetConstructor(new[] { _absNodeT, typeof(int) }) ?? throw new InvalidOperationException("SlotReference ctor"); + + _serialize = _multiJsonT.GetMethod("Serialize", SF); + _deserializeGeneric = _multiJsonT.GetMethods(SF).FirstOrDefault(m => m.Name == "Deserialize" && m.IsGenericMethod); + _writeToDisk = _fileUtilT.GetMethod("WriteToDisk", SF); + _writeGraphToDisk = _fileUtilT.GetMethod("WriteShaderGraphToDisk", SF); + + if (_addContexts == null || _initOutputs == null || _getActiveBlocks == null || _addRemoveBlocks == null || + _onEnable == null || _addNode == null || _getNodeFromId == null || _connect == null || _removeEdge == null || + _getNodesGeneric == null || _serialize == null || _deserializeGeneric == null || _writeToDisk == null || _writeGraphToDisk == null) + throw new InvalidOperationException("One or more ShaderGraph API methods not found (version mismatch)"); + } + + private static MethodInfo _urpTargetTryResolve(Assembly urp) + { + if (urp == null) return null; + _urpTargetT = urp.GetType("UnityEditor.Rendering.Universal.ShaderGraph.UniversalTarget"); + _litSubT = urp.GetType("UnityEditor.Rendering.Universal.ShaderGraph.UniversalLitSubTarget"); + _unlitSubT = urp.GetType("UnityEditor.Rendering.Universal.ShaderGraph.UniversalUnlitSubTarget"); + _spriteLitSubT = urp.GetType("UnityEditor.Rendering.Universal.ShaderGraph.UniversalSpriteLitSubTarget"); + _spriteUnlitSubT = urp.GetType("UnityEditor.Rendering.Universal.ShaderGraph.UniversalSpriteUnlitSubTarget"); + return _urpTargetT?.GetMethod("TrySetActiveSubTarget"); + } + + // ───────────────────────────────────────────────────────────── + // High-level operations + // ───────────────────────────────────────────────────────────── + + /// Map a template name to a URP SubTarget type; null = blank (no target). + internal static Type ResolveTemplateSubTarget(string template) + { + switch ((template ?? "").ToLowerInvariant()) + { + case "urp_lit": + case "lit": + case "": return _litSubT; + case "urp_unlit": + case "unlit": return _unlitSubT; + case "urp_sprite_lit": + case "sprite_lit": return _spriteLitSubT; + case "urp_sprite_unlit": + case "sprite_unlit": return _spriteUnlitSubT; + case "blank": + case "empty": return null; + default: return _litSubT; + } + } + + internal static bool IsUrpAvailable => _urpTargetT != null && _trySetSubTarget != null; + + /// + /// Create a valid new graph on disk. subTargetType null → a blank graph (valid, no + /// active target; the user picks one in-editor). Returns null on success, else an error message. + /// + internal static string CreateGraph(string assetPath, string fullPath, Type subTargetType) + { + var graph = NewGraph(); + _addContexts.Invoke(graph, null); + + if (subTargetType != null) + { + if (!IsUrpAvailable) return "URP Shader Graph targets are not available in this project; use template 'blank'."; + var target = Activator.CreateInstance(_urpTargetT); + _trySetSubTarget.Invoke(target, new object[] { subTargetType }); + var targets = Array.CreateInstance(_targetT, 1); + targets.SetValue(target, 0); + _initOutputs.Invoke(graph, new object[] { targets, Array.CreateInstance(_blockFieldT, 0) }); + _addRemoveBlocks.Invoke(graph, new object[] { _getActiveBlocks.Invoke(graph, null) }); + } + + _onEnable.Invoke(graph, null); + string text = (string)_serialize.Invoke(null, new object[] { graph }); + _writeToDisk.Invoke(null, new object[] { assetPath, text }); + AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceSynchronousImport); + return null; + } + + /// Load an existing .shadergraph into a live, mutable GraphData. + internal static object LoadGraph(string fullPath) + { + string text = File.ReadAllText(fullPath); + var graph = NewGraph(); + _deserializeGeneric.MakeGenericMethod(_graphDataT).Invoke(null, new object[] { graph, text, null, false }); + _onEnable.Invoke(graph, null); + return graph; + } + + /// Serialize a mutated graph back to disk and reimport. + internal static void SaveGraph(string assetPath, object graph) + { + _writeGraphToDisk.Invoke(null, new object[] { assetPath, graph }); + AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceSynchronousImport); + } + + private static object NewGraph() + { + var graph = Activator.CreateInstance(_graphDataT); + _messageManagerProp.SetValue(graph, Activator.CreateInstance(_messageManagerT)); + return graph; + } + + /// Instantiate a node type, set its position, and add it. Returns its objectId. + internal static string AddNode(object graph, Type nodeType, float x, float y) + { + var node = Activator.CreateInstance(nodeType); + var draw = _drawStateProp.GetValue(node); + _drawPositionProp.SetValue(draw, new Rect(x, y, 208, 300)); + _drawStateProp.SetValue(node, draw); // DrawState is a struct — write back + _addNode.Invoke(graph, new object[] { node }); + return (string)_objectIdProp.GetValue(node); + } + + internal static object GetNodeFromId(object graph, string nodeId) => _getNodeFromId.Invoke(graph, new object[] { nodeId }); + + /// Find a slot id on a node by input/output and (optional) explicit id. Returns int.MinValue if none. + internal static int FindSlotId(object node, bool wantInput, int explicitId) + { + var listT = typeof(List<>).MakeGenericType(_materialSlotT); + var list = Activator.CreateInstance(listT); + _getSlotsGeneric.MakeGenericMethod(_materialSlotT).Invoke(node, new object[] { list }); + foreach (var slot in (IEnumerable)list) + { + bool isInput = (bool)slot.GetType().GetProperty("isInputSlot").GetValue(slot); + int id = (int)slot.GetType().GetProperty("id").GetValue(slot); + if (isInput != wantInput) continue; + if (explicitId != int.MinValue) { if (id == explicitId) return id; } + else return id; // first matching-direction slot + } + return int.MinValue; + } + + /// Connect output(node,slot) → input(node,slot). Returns null on success or an error. + internal static string Connect(object graph, object outNode, int outSlot, object inNode, int inSlot) + { + var outRef = _slotRefCtor.Invoke(new object[] { outNode, outSlot }); + var inRef = _slotRefCtor.Invoke(new object[] { inNode, inSlot }); + var edge = _connect.Invoke(graph, new object[] { outRef, inRef }); + return edge == null ? "Connection refused by ShaderGraph (incompatible slots or would create a cycle)." : null; + } + + /// Remove edges matching the 4-tuple (any component <0/null = wildcard). Returns removed count. + internal static int Disconnect(object graph, string outNodeId, int outSlot, string inNodeId, int inSlot) + { + var toRemove = new List(); + foreach (var edge in (IEnumerable)_edgesProp.GetValue(graph)) + { + var outSlotRef = edge.GetType().GetProperty("outputSlot").GetValue(edge); + var inSlotRef = edge.GetType().GetProperty("inputSlot").GetValue(edge); + string eOut = SlotRefNodeId(outSlotRef); + string eIn = SlotRefNodeId(inSlotRef); + int eOutSlot = (int)outSlotRef.GetType().GetProperty("slotId").GetValue(outSlotRef); + int eInSlot = (int)inSlotRef.GetType().GetProperty("slotId").GetValue(inSlotRef); + + if (outNodeId != null && eOut != outNodeId) continue; + if (inNodeId != null && eIn != inNodeId) continue; + if (outSlot >= 0 && eOutSlot != outSlot) continue; + if (inSlot >= 0 && eInSlot != inSlot) continue; + toRemove.Add(edge); + } + foreach (var edge in toRemove) + _removeEdge.Invoke(graph, new object[] { edge }); + return toRemove.Count; + } + + private static string SlotRefNodeId(object slotRef) + { + // SlotReference exposes the owning node; read its objectId. + var nodeProp = slotRef.GetType().GetProperty("node", BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic); + var node = nodeProp?.GetValue(slotRef); + return node != null ? (string)_objectIdProp.GetValue(node) : null; + } + + internal static Type ResolveNodeType(string name) => MCPShaderGraphCommands.ResolveShaderGraphNodeType(name); + } +} diff --git a/Editor/MCPShaderGraphApi.cs.meta b/Editor/MCPShaderGraphApi.cs.meta new file mode 100644 index 0000000..1d4f030 --- /dev/null +++ b/Editor/MCPShaderGraphApi.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c5528d1ff08b4c278dab088262e95bda +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MCPShaderGraphCommands.cs b/Editor/MCPShaderGraphCommands.cs index 50651ad..f13656c 100644 --- a/Editor/MCPShaderGraphCommands.cs +++ b/Editor/MCPShaderGraphCommands.cs @@ -394,84 +394,39 @@ public static object CreateShaderGraph(Dictionary args) string template = args.ContainsKey("template") ? args["template"].ToString().ToLower() : "urp_lit"; + if (!MCPShaderGraphApi.Available) + return new Dictionary { { "error", "ShaderGraph real-API access unavailable: " + MCPShaderGraphApi.UnavailableReason } }; + + // Resolve under the project root; guard against overwriting an existing graph. + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; + var overwriteError = MCPAssetSafety.OverwriteGuard(path, args); + if (overwriteError != null) + return overwriteError is Dictionary d ? d : new Dictionary { { "error", overwriteError.ToString() } }; + try { - // Try using ShaderGraph's internal API to create via menu items - // This is the most reliable approach as the JSON format is complex and version-dependent - - // First ensure directory exists - string dir = Path.GetDirectoryName(Path.Combine(Application.dataPath, "..", path)); - if (!Directory.Exists(dir)) + string dir = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - // Use ProjectWindowUtil for reliable creation - bool created = false; - - // Try menu item approach - create in a temp location then move - string menuPath = GetMenuPathForTemplate(template); - - if (!string.IsNullOrEmpty(menuPath)) - { - // Select the target folder first - string folderPath = Path.GetDirectoryName(path); - var folder = AssetDatabase.LoadAssetAtPath(folderPath); - if (folder != null) - Selection.activeObject = folder; - - // Create using internal API via reflection - try - { - // Try to find the shader graph creation type - Type createActionType = null; - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) - { - if (asm.GetName().Name == "Unity.ShaderGraph.Editor") - { - createActionType = asm.GetType("UnityEditor.ShaderGraph.CreateShaderGraph"); - break; - } - } - - if (createActionType != null) - { - // Invoke the creation method - var createMethod = createActionType.GetMethod("CreateGraph", - BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic); - if (createMethod != null) - { - createMethod.Invoke(null, new object[] { path }); - created = true; - } - } - } - catch { } - } - - // Fallback: create a minimal .shadergraph file - if (!created) - { - string graphContent = GetMinimalShaderGraphJson(template, Path.GetFileNameWithoutExtension(path)); - string fullPath = Path.Combine(Application.dataPath, "..", path); - File.WriteAllText(fullPath, graphContent); - AssetDatabase.ImportAsset(path); - created = true; - } - - if (created) - { - AssetDatabase.Refresh(); - return new Dictionary - { - { "success", true }, - { "assetPath", path }, - { "template", template }, - { "note", "Shader graph created. Open it in the Shader Graph editor to add nodes." }, - }; - } + // Build the graph through ShaderGraph's real GraphData model so the output is + // always a valid, importable asset (the old hand-built JSON produced an + // invalid graph with a dangling output node — issue #18 bug 1). + Type subTarget = MCPShaderGraphApi.ResolveTemplateSubTarget(template); + string createError = MCPShaderGraphApi.CreateGraph(MCPAssetSafety.ToAssetDatabasePath(path), fullPath, subTarget); + if (createError != null) + return new Dictionary { { "error", createError } }; + bool blank = subTarget == null; return new Dictionary { - { "error", "Failed to create shader graph. Try creating it manually via Assets > Create > Shader Graph." }, + { "success", true }, + { "assetPath", path }, + { "template", template }, + { "note", blank + ? "Blank shader graph created (no active target). Open it and add a target, or use a 'urp_lit'/'urp_unlit' template." + : "Shader graph created with a URP target and its default blocks. Add nodes with shadergraph/add-node." }, }; } catch (Exception ex) @@ -617,58 +572,9 @@ public static object OpenVFXGraph(Dictionary args) } // ─── Helpers ─── - - private static string GetMenuPathForTemplate(string template) - { - switch (template) - { - case "urp_lit": return "Assets/Create/Shader Graph/URP/Lit Shader Graph"; - case "urp_unlit": return "Assets/Create/Shader Graph/URP/Unlit Shader Graph"; - case "urp_sprite_lit": return "Assets/Create/Shader Graph/URP/Sprite Lit Shader Graph"; - case "urp_sprite_unlit": return "Assets/Create/Shader Graph/URP/Sprite Unlit Shader Graph"; - case "urp_decal": return "Assets/Create/Shader Graph/URP/Decal Shader Graph"; - case "hdrp_lit": return "Assets/Create/Shader Graph/HDRP/Lit Shader Graph"; - case "hdrp_unlit": return "Assets/Create/Shader Graph/HDRP/Unlit Shader Graph"; - case "blank": return "Assets/Create/Shader Graph/Blank Shader Graph"; - default: return null; - } - } - - private static string GetMinimalShaderGraphJson(string template, string name) - { - // Minimal valid .shadergraph file structure - // This creates a basic graph that Unity can parse and open in the editor - return $@"{{ - ""m_SGVersion"": 3, - ""m_Type"": ""UnityEditor.ShaderGraph.GraphData"", - ""m_ObjectId"": ""{Guid.NewGuid():N}"", - ""m_Properties"": [], - ""m_Keywords"": [], - ""m_Dropdowns"": [], - ""m_CategoryData"": [], - ""m_Nodes"": [], - ""m_GroupDatas"": [], - ""m_StickyNoteDatas"": [], - ""m_Edges"": [], - ""m_VertexContext"": {{ - ""m_Position"": {{ ""x"": 0.0, ""y"": 0.0 }}, - ""m_Blocks"": [] - }}, - ""m_FragmentContext"": {{ - ""m_Position"": {{ ""x"": 200.0, ""y"": 0.0 }}, - ""m_Blocks"": [] - }}, - ""m_PreviewData"": {{ - ""serializedMesh"": {{ ""m_SerializedMesh"": """", ""m_Guid"": """" }} - }}, - ""m_Path"": ""Shader Graphs"", - ""m_GraphPrecision"": 1, - ""m_PreviewMode"": 2, - ""m_OutputNode"": {{ - ""m_Id"": ""{Guid.NewGuid():N}"" - }} -}}"; - } + // (Graph creation and editing now go through MCPShaderGraphApi / the real + // ShaderGraph GraphData model; the old hand-built JSON template and node + // templates were removed — they produced invalid/corrupt graphs, issue #18.) private static object PackageNotInstalledError(string packageName) { @@ -820,61 +726,29 @@ public static object AddGraphNode(Dictionary args) string path = args["path"].ToString(); string nodeType = args["nodeType"].ToString(); - float posX = args.ContainsKey("positionX") ? Convert.ToSingle(args["positionX"]) : 0f; - float posY = args.ContainsKey("positionY") ? Convert.ToSingle(args["positionY"]) : 0f; - - string fullPath = Path.Combine(Application.dataPath, "..", path); + // Accept positionX/positionY (canonical) and x/y (common alias). + float posX = args.ContainsKey("positionX") ? Convert.ToSingle(args["positionX"]) : args.ContainsKey("x") ? Convert.ToSingle(args["x"]) : 0f; + float posY = args.ContainsKey("positionY") ? Convert.ToSingle(args["positionY"]) : args.ContainsKey("y") ? Convert.ToSingle(args["y"]) : 0f; + + if (!MCPShaderGraphApi.Available) + return new Dictionary { { "error", "ShaderGraph real-API access unavailable: " + MCPShaderGraphApi.UnavailableReason } }; + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; if (!File.Exists(fullPath)) return new Dictionary { { "error", $"File not found: {path}" } }; + Type resolvedType = ResolveShaderGraphNodeType(nodeType); + if (resolvedType == null) + return new Dictionary { { "error", $"Unknown node type: {nodeType}. Use 'shadergraph/get-node-types' to list available types." } }; + try { - // Find the node type in ShaderGraph assembly - Type resolvedType = ResolveShaderGraphNodeType(nodeType); - - string nodeId = Guid.NewGuid().ToString("N").Substring(0, 24); - string nodeJson; - - if (resolvedType != null) - { - // Try to serialize via reflection - nodeJson = TrySerializeNodeViaReflection(resolvedType, nodeId, posX, posY); - } - else - { - // Use template-based approach for common types - nodeJson = GetNodeTemplate(nodeType, nodeId, posX, posY); - } - - if (string.IsNullOrEmpty(nodeJson)) - return new Dictionary - { - { "error", $"Unknown node type: {nodeType}. Use 'shadergraph/get-node-types' to list available types." }, - }; - - // Read the file and insert the node - string content = File.ReadAllText(fullPath); - - // Add node reference to the main GraphData block - string nodeRef = $"{{\"m_Id\":\"{nodeId}\"}}"; - - // Find m_Nodes array in the graph data and add the reference - int nodesArrayEnd = FindJsonArrayEnd(content, "m_Nodes"); - if (nodesArrayEnd < 0) - return new Dictionary { { "error", "Could not find m_Nodes array in graph file" } }; - - // Insert reference before the closing bracket of m_Nodes - string nodesArrayContent = content.Substring(0, nodesArrayEnd); - bool hasExistingNodes = nodesArrayContent.TrimEnd().EndsWith("}"); - string separator = hasExistingNodes ? "," : ""; - content = content.Insert(nodesArrayEnd, separator + nodeRef); - - // Append the full node JSON as a new block at the end of the file - // In MultiJson format, each object is a separate top-level JSON - content = content.TrimEnd() + "\n\n" + nodeJson; - - File.WriteAllText(fullPath, content); - AssetDatabase.ImportAsset(path); + // Load → mutate → save through the real GraphData model. Nodes get their real + // slots and the position is honored; the graph is guaranteed valid on save + // (old string-surgery produced empty-slot nodes and dropped the position — #18 bugs 3,4). + var graph = MCPShaderGraphApi.LoadGraph(fullPath); + string nodeId = MCPShaderGraphApi.AddNode(graph, resolvedType, posX, posY); + MCPShaderGraphApi.SaveGraph(MCPAssetSafety.ToAssetDatabasePath(path), graph); return new Dictionary { @@ -883,7 +757,6 @@ public static object AddGraphNode(Dictionary args) { "nodeId", nodeId }, { "nodeType", nodeType }, { "position", new Dictionary { { "x", posX }, { "y", posY } } }, - { "note", "Node added. The graph will update when opened in Shader Graph editor." }, }; } catch (Exception ex) @@ -984,29 +857,27 @@ public static object ConnectGraphNodes(Dictionary args) string inputNodeId = args["inputNodeId"].ToString(); int inputSlotId = Convert.ToInt32(args["inputSlotId"]); - string fullPath = Path.Combine(Application.dataPath, "..", path); + if (!MCPShaderGraphApi.Available) + return new Dictionary { { "error", "ShaderGraph real-API access unavailable: " + MCPShaderGraphApi.UnavailableReason } }; + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; if (!File.Exists(fullPath)) return new Dictionary { { "error", $"File not found: {path}" } }; try { - string content = File.ReadAllText(fullPath); - - // Build edge JSON - string edgeJson = $"{{\"m_OutputSlot\":{{\"m_Node\":{{\"m_Id\":\"{outputNodeId}\"}},\"m_SlotId\":{outputSlotId}}},\"m_InputSlot\":{{\"m_Node\":{{\"m_Id\":\"{inputNodeId}\"}},\"m_SlotId\":{inputSlotId}}}}}"; - - // Find m_Edges array and insert - int edgesArrayEnd = FindJsonArrayEnd(content, "m_Edges"); - if (edgesArrayEnd < 0) - return new Dictionary { { "error", "Could not find m_Edges array in graph file" } }; - - string beforeEnd = content.Substring(0, edgesArrayEnd).TrimEnd(); - bool hasExistingEdges = beforeEnd.EndsWith("}"); - string separator = hasExistingEdges ? "," : ""; - content = content.Insert(edgesArrayEnd, separator + edgeJson); - - File.WriteAllText(fullPath, content); - AssetDatabase.ImportAsset(path); + var graph = MCPShaderGraphApi.LoadGraph(fullPath); + var outNode = MCPShaderGraphApi.GetNodeFromId(graph, outputNodeId); + var inNode = MCPShaderGraphApi.GetNodeFromId(graph, inputNodeId); + if (outNode == null) return new Dictionary { { "error", $"Output node not found: {outputNodeId}" } }; + if (inNode == null) return new Dictionary { { "error", $"Input node not found: {inputNodeId}" } }; + + // graph.Connect validates slot compatibility and refuses cycles, so the saved + // graph is always valid (unlike the old blind edge-JSON insertion). + string connectError = MCPShaderGraphApi.Connect(graph, outNode, outputSlotId, inNode, inputSlotId); + if (connectError != null) + return new Dictionary { { "error", connectError } }; + MCPShaderGraphApi.SaveGraph(MCPAssetSafety.ToAssetDatabasePath(path), graph); return new Dictionary { @@ -1043,64 +914,27 @@ public static object DisconnectGraphNodes(Dictionary args) int outputSlotId = args.ContainsKey("outputSlotId") ? Convert.ToInt32(args["outputSlotId"]) : -1; int inputSlotId = args.ContainsKey("inputSlotId") ? Convert.ToInt32(args["inputSlotId"]) : -1; - string fullPath = Path.Combine(Application.dataPath, "..", path); + if (!MCPShaderGraphApi.Available) + return new Dictionary { { "error", "ShaderGraph real-API access unavailable: " + MCPShaderGraphApi.UnavailableReason } }; + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; if (!File.Exists(fullPath)) return new Dictionary { { "error", $"File not found: {path}" } }; try { - string content = File.ReadAllText(fullPath); - int removed = 0; - - // Find and remove matching edges - var edges = ParseEdgesFromJson(content); - var edgesToKeep = new List(); - - // Rebuild edges array, skipping the one to remove - int edgesStart = content.IndexOf("\"m_Edges\""); - if (edgesStart < 0) - return new Dictionary { { "error", "Could not find m_Edges in graph file" } }; - - int arrayStart = content.IndexOf('[', edgesStart); - int arrayEnd = FindMatchingBracket(content, arrayStart); - - string edgesArray = content.Substring(arrayStart, arrayEnd - arrayStart + 1); - - // Remove edges matching criteria - foreach (var edge in edges) - { - string eOut = edge.ContainsKey("outputNodeId") ? edge["outputNodeId"].ToString() : ""; - string eIn = edge.ContainsKey("inputNodeId") ? edge["inputNodeId"].ToString() : ""; - - if (eOut == outputNodeId && eIn == inputNodeId) - { - if (outputSlotId >= 0 && edge.ContainsKey("outputSlotId")) - { - if (Convert.ToInt32(edge["outputSlotId"]) != outputSlotId) continue; - } - if (inputSlotId >= 0 && edge.ContainsKey("inputSlotId")) - { - if (Convert.ToInt32(edge["inputSlotId"]) != inputSlotId) continue; - } - removed++; - continue; // Skip this edge - } - - // Reconstruct edge JSON - edgesToKeep.Add($"{{\"m_OutputSlot\":{{\"m_Node\":{{\"m_Id\":\"{eOut}\"}},\"m_SlotId\":{edge["outputSlotId"]}}},\"m_InputSlot\":{{\"m_Node\":{{\"m_Id\":\"{eIn}\"}},\"m_SlotId\":{edge["inputSlotId"]}}}}}"); - } - - string newEdgesArray = "[" + string.Join(",", edgesToKeep) + "]"; - content = content.Substring(0, arrayStart) + newEdgesArray + content.Substring(arrayEnd + 1); - - File.WriteAllText(fullPath, content); - AssetDatabase.ImportAsset(path); + // Remove exactly the edges matching the full tuple, via the real edge model — + // the old parse-and-rebuild matched by output slot only (dropping sibling edges) + // and blanked surviving multi-line edges to m_Id:"" (issue #18 bug 2). + var graph = MCPShaderGraphApi.LoadGraph(fullPath); + int removed = MCPShaderGraphApi.Disconnect(graph, outputNodeId, outputSlotId, inputNodeId, inputSlotId); + MCPShaderGraphApi.SaveGraph(MCPAssetSafety.ToAssetDatabasePath(path), graph); return new Dictionary { { "success", true }, + { "assetPath", path }, { "removedEdges", removed }, - { "remainingEdges", edgesToKeep.Count }, }; } catch (Exception ex) @@ -1462,7 +1296,7 @@ private static string SetJsonProperty(string block, string propertyName, string return block; // Property not found } - private static Type ResolveShaderGraphNodeType(string typeName) + internal static Type ResolveShaderGraphNodeType(string typeName) { try { @@ -1492,123 +1326,5 @@ private static Type ResolveShaderGraphNodeType(string typeName) return null; } - private static string TrySerializeNodeViaReflection(Type nodeType, string nodeId, float posX, float posY) - { - try - { - // Create instance - var node = Activator.CreateInstance(nodeType); - if (node == null) return null; - - // Use JsonUtility to get a baseline serialization - string serialized = JsonUtility.ToJson(node, true); - - // Inject our ID and position - if (!serialized.Contains("m_ObjectId")) - serialized = serialized.TrimEnd('}') + $",\"m_ObjectId\":\"{nodeId}\"}}"; - else - serialized = System.Text.RegularExpressions.Regex.Replace( - serialized, "\"m_ObjectId\"\\s*:\\s*\"[^\"]*\"", $"\"m_ObjectId\":\"{nodeId}\""); - - // Inject type info - if (!serialized.Contains("m_Type")) - serialized = serialized.TrimEnd('}') + $",\"m_Type\":\"{nodeType.FullName}\"}}"; - - // Add draw state with position - if (!serialized.Contains("m_DrawState")) - { - string drawState = $"\"m_DrawState\":{{\"m_Expanded\":true,\"m_Position\":{{\"serializedVersion\":\"2\",\"x\":{posX},\"y\":{posY},\"width\":208,\"height\":311}}}}"; - serialized = serialized.TrimEnd('}') + "," + drawState + "}"; - } - - return serialized; - } - catch - { - return null; - } - } - - private static string GetNodeTemplate(string nodeType, string nodeId, float posX, float posY) - { - string lower = nodeType.ToLowerInvariant(); - - // Common node templates - string position = $"\"x\":{posX},\"y\":{posY},\"width\":208,\"height\":311"; - string drawState = $"\"m_DrawState\":{{\"m_Expanded\":true,\"m_Position\":{{\"serializedVersion\":\"2\",{position}}}}}"; - - switch (lower) - { - case "add": - case "addnode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.AddNode\",\"m_Name\":\"Add\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "multiply": - case "multiplynode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.MultiplyNode\",\"m_Name\":\"Multiply\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "subtract": - case "subtractnode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.SubtractNode\",\"m_Name\":\"Subtract\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "divide": - case "dividenode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.DivideNode\",\"m_Name\":\"Divide\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "lerp": - case "lerpnode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.LerpNode\",\"m_Name\":\"Lerp\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "color": - case "colornode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.ColorNode\",\"m_Name\":\"Color\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[],\"m_Color\":{{\"r\":1,\"g\":1,\"b\":1,\"a\":1}}}}"; - case "float": - case "vector1": - case "vector1node": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.Vector1Node\",\"m_Name\":\"Float\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[],\"m_Value\":0}}"; - case "vector2": - case "vector2node": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.Vector2Node\",\"m_Name\":\"Vector 2\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "vector3": - case "vector3node": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.Vector3Node\",\"m_Name\":\"Vector 3\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "vector4": - case "vector4node": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.Vector4Node\",\"m_Name\":\"Vector 4\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "time": - case "timenode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.TimeNode\",\"m_Name\":\"Time\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "uv": - case "uvnode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.UVNode\",\"m_Name\":\"UV\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "position": - case "positionnode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.PositionNode\",\"m_Name\":\"Position\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "normal": - case "normalnode": - case "normalvector": - case "normalvectornode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.NormalVectorNode\",\"m_Name\":\"Normal Vector\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "sampletexture2d": - case "sampletexture2dnode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.SampleTexture2DNode\",\"m_Name\":\"Sample Texture 2D\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "fresnel": - case "fresneleffect": - case "fresneleffectnode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.FresnelEffectNode\",\"m_Name\":\"Fresnel Effect\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "saturate": - case "saturatenode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.SaturateNode\",\"m_Name\":\"Saturate\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "oneminusx": - case "oneminusnode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.OneMinusNode\",\"m_Name\":\"One Minus\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "power": - case "powernode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.PowerNode\",\"m_Name\":\"Power\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "split": - case "splitnode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.SplitNode\",\"m_Name\":\"Split\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - case "combine": - case "combinenode": - return $"{{\"m_ObjectId\":\"{nodeId}\",\"m_Type\":\"UnityEditor.ShaderGraph.CombineNode\",\"m_Name\":\"Combine\",{drawState},\"m_Slots\":[],\"m_SerializableSlots\":[]}}"; - default: - return null; - } - } } } From cf34c981e7fc11432cac5c813b9f25311d46930b Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Sat, 18 Jul 2026 20:44:17 +0200 Subject: [PATCH 06/15] [FIX] Review fixes (data-safety + ShaderGraph round-2 review, was B-/Block -> address all HIGH) : - HIGH-1 script/update still truncated on content:"" (guard only caught missing/null) : now rejects empty like Create. Live-verified - HIGH-2 OverwriteGuard checked the RAW path against AssetDatabase while the write used the canonical path -> a double slash / . / .. spelling (or a not-yet-imported file) bypassed it and clobbered the existing asset : AssetWouldOverwrite now checks File.Exists(canonical) OR the DB. Live-verified Assets//X blocked - HIGH-3 remove-node was still the regex edge-blanking corruption path (mislabeled read-only) : migrated to real GraphData.RemoveNode via MCPShaderGraphApi; set-node-property now path-confined. Live-verified remove-node keeps the graph valid + compiling - M-1 confinement now enforces its stated contract (must be under Assets/ or Packages/) instead of anywhere-under-root (ProjectSettings/.git/Library no longer writable). Live-verified ProjectSettings rejected - M-2 pin the 4-arg MultiJson.Deserialize shape at init (fail closed on arity drift, not TargetParameterCountException at call time) - M-3 create no longer silently downgrades an explicit URP template to blank when URP is absent -> clear error - M-5 asmdef remove-references no-match returns a warning instead of a false success + needless recompile - LOW: removed the dead File.Exists/is-Dictionary guard in shadergraph create (overwrite:true now works there too); editor/state + project/info report the project path via the corrected root helper; TryResolveProjectPath IsPathRooted moved inside try; case-sensitivity uses Ordinal on Linux - CHANGELOG 2.34.0 documents the data-safety + ShaderGraph work AND the overwrite:true breaking change --- CHANGELOG.md | 21 ++++++++++ Editor/MCPAssemblyDefCommands.cs | 13 ++++++ Editor/MCPAssetSafety.cs | 58 +++++++++++++++++---------- Editor/MCPEditorCommands.cs | 2 +- Editor/MCPProjectCommands.cs | 2 +- Editor/MCPScriptCommands.cs | 12 +++--- Editor/MCPShaderGraphApi.cs | 26 ++++++++++-- Editor/MCPShaderGraphCommands.cs | 68 +++++++++++--------------------- package.json | 2 +- 9 files changed, 129 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae91bef..ab6d2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this package will be documented in this file. +## [2.34.0] - 2026-07-18 + +### Fixed (data safety — several handlers could silently destroy user assets) +- **`script/create` and `script/update` no longer write outside the project or destroy source files** — the project root was computed with `dataPath.Replace("/Assets","")`, which strips **every** `/Assets` occurrence, so a project under a path containing `/Assets` resolved to the wrong root and writes landed outside the project (the import then silently no-oped). Root is now `Path.GetDirectoryName(Application.dataPath)`. `update` with missing **or empty** content used to truncate the target script to zero bytes — empty content is now rejected. `create` no longer silently overwrites an existing script. All paths are canonicalized and confined under `Assets/`/`Packages/` (traversal and absolute-path escapes rejected). +- **Asset creators no longer silently overwrite existing assets** — `AssetDatabase.CreateAsset` on an existing path destroys the asset and every reference to it (the GUID is reused). ScriptableObject, Terrain (`create`), Animator Controller, Animation Clip, Material, and `asset/import` now refuse to replace an existing asset unless `overwrite: true` is passed. The overwrite check tests the canonical on-disk path **and** the AssetDatabase, so a non-canonical spelling (`Assets//X`, `Assets/./X`) or a not-yet-imported file can't slip past it. New shared `MCPAssetSafety` helper centralizes root resolution, path confinement, and the overwrite guard. +- **Queue double-execution** — a request whose 30s sync waiter already gave up (`TimedOut`) stayed in the queue and still executed later, so a client retry ran a non-idempotent action (create, package add, import) twice. Timed-out tickets are now dropped before execution (race-safe under the queue lock). +- **`component/set-property` reported success without applying the value** — Color/Vector2/3/4/Rect branches silently did nothing when the value wasn't an object (e.g. an agent passing `"1,0,0,1"` or an array); they now return a clear error. +- **`asmdef` remove-references removed the wrong reference** — a substring `Contains` match removed `Unity.InputSystem.ForUI` when asked to remove `Unity.InputSystem` (breaking compilation while reporting success); now an exact name/GUID match, and a no-match is reported rather than churning a recompile. + +### Fixed (ShaderGraph — all four asset-corruption bugs in [#18](https://github.com/AnkleBreaker-Studio/unity-mcp-plugin/issues/18)) +- ShaderGraph create/add-node/connect/disconnect/remove-node now go through ShaderGraph's real `GraphData` model (new `MCPShaderGraphApi` reflection wrapper over `GraphData` / `MultiJson` / `FileUtilities`) instead of regex/JSON string surgery, so the serialized `.shadergraph` is **always** a valid, importable asset: + - `create` built an identical invalid 809-byte graph for every template (no target, dangling `m_OutputNode`, failed import); it now builds a real URP Lit/Unlit graph with its default surface/vertex blocks (or a valid target-less graph for `blank`), and refuses to silently downgrade an explicit URP template to blank when URP isn't installed. + - `add-node` produced empty-slot nodes that broke import and dropped the requested position; nodes now get their real slots and the position is honored (`positionX`/`positionY` or `x`/`y`). + - `disconnect` matched by output slot only (dropping sibling edges) and blanked surviving multi-line edges to `m_Id:""`; it now removes exactly the tuple-matched edges, survivors untouched. + - `remove-node` had the same edge-blanking corruption; it now uses `GraphData.RemoveNode`. + - `connect` validates slot compatibility and refuses cycles instead of blindly inserting edge JSON. + - Fail-closed: if the ShaderGraph API can't be resolved (version drift), these handlers return a clear error instead of corrupting anything. + +### Changed +- **`overwrite: true` opt-in** on the asset-creator handlers is the escape hatch for the new overwrite guards (see the companion `unity-mcp-server` 2.32.0 schema additions). `editor/state` and `project/info` now report the project path via the corrected root helper. + ## [2.33.0] - 2026-07-18 ### Fixed diff --git a/Editor/MCPAssemblyDefCommands.cs b/Editor/MCPAssemblyDefCommands.cs index 7546dda..826f5d0 100644 --- a/Editor/MCPAssemblyDefCommands.cs +++ b/Editor/MCPAssemblyDefCommands.cs @@ -265,6 +265,19 @@ public static object RemoveReferences(Dictionary args) } } + // Nothing matched — don't churn a rewrite + recompile for a no-op, and don't + // report a misleading success (exact-match now, so a stale name simply isn't there). + if (removed.Count == 0) + { + return new + { + success = false, + path = path, + removed = removed, + warning = "No matching references found (exact name/GUID match). Nothing was changed.", + }; + } + asmdef["references"] = existingRefs.Cast().ToList(); json = FormatAsmdefJson(asmdef); diff --git a/Editor/MCPAssetSafety.cs b/Editor/MCPAssetSafety.cs index 89fff9f..edf091c 100644 --- a/Editor/MCPAssetSafety.cs +++ b/Editor/MCPAssetSafety.cs @@ -34,24 +34,23 @@ internal static bool TryResolveProjectPath(string assetPath, out string fullPath fullPath = null; error = null; - if (string.IsNullOrEmpty(assetPath)) + if (string.IsNullOrWhiteSpace(assetPath)) { error = "path is required"; return false; } - // Reject rooted/absolute inputs outright: Path.Combine returns the second - // argument verbatim when it is rooted, which would escape the project. - if (Path.IsPathRooted(assetPath)) - { - error = $"path must be project-relative (under Assets/ or Packages/), got absolute: {assetPath}"; - return false; - } - - string root = ProjectRoot; - string combined; + string root, rootFull, combined; try { + // IsPathRooted throws on invalid path chars on Mono — keep it inside the try. + if (Path.IsPathRooted(assetPath)) + { + error = $"path must be project-relative (under Assets/ or Packages/), got absolute: {assetPath}"; + return false; + } + root = ProjectRoot; + rootFull = Path.GetFullPath(root); combined = Path.GetFullPath(Path.Combine(root, assetPath)); } catch (Exception ex) @@ -60,18 +59,31 @@ internal static bool TryResolveProjectPath(string assetPath, out string fullPath return false; } - string rootFull = Path.GetFullPath(root); - string rootPrefix = rootFull.EndsWith(Path.DirectorySeparatorChar.ToString()) - ? rootFull - : rootFull + Path.DirectorySeparatorChar; + string sep = Path.DirectorySeparatorChar.ToString(); + string rootPrefix = rootFull.EndsWith(sep) ? rootFull : rootFull + sep; + + // Path comparison is case-insensitive only where the filesystem is (Windows/macOS); + // on Linux it must be ordinal so a case-variant sibling can't slip through. + var cmp = Application.platform == RuntimePlatform.LinuxEditor + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; - // Case-insensitive on Windows/macOS default filesystems; ordinal is the safe superset. - if (!combined.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) + if (!combined.StartsWith(rootPrefix, cmp)) { error = $"path escapes the project root: {assetPath}"; return false; } + // Enforce the stated contract: writes/reads live under Assets/ or Packages/, + // never project metadata (ProjectSettings/, Library/, .git/, ...). + string relative = combined.Substring(rootPrefix.Length).Replace('\\', '/'); + string firstSegment = relative.Split('/')[0]; + if (!firstSegment.Equals("Assets", cmp) && !firstSegment.Equals("Packages", cmp)) + { + error = $"path must be under Assets/ or Packages/, got: {assetPath}"; + return false; + } + fullPath = combined; return true; } @@ -82,11 +94,17 @@ internal static string ToAssetDatabasePath(string assetPath) return assetPath.Replace('\\', '/'); } - /// True if an asset already exists at this project path (AssetDatabase view). + /// + /// True if something already lives at this project path — checked against BOTH the + /// canonical on-disk path AND the AssetDatabase. A raw-string DB lookup alone was + /// bypassable by a non-canonical spelling ("Assets//X", "Assets/./X") and blind to a + /// file written moments earlier but not yet imported. + /// internal static bool AssetWouldOverwrite(string assetPath) { - string dbPath = ToAssetDatabasePath(assetPath); - return AssetDatabase.LoadMainAssetAtPath(dbPath) != null; + if (TryResolveProjectPath(assetPath, out string fullPath, out _) && File.Exists(fullPath)) + return true; + return AssetDatabase.LoadMainAssetAtPath(ToAssetDatabasePath(assetPath)) != null; } /// diff --git a/Editor/MCPEditorCommands.cs b/Editor/MCPEditorCommands.cs index 2d80be4..90d20e6 100755 --- a/Editor/MCPEditorCommands.cs +++ b/Editor/MCPEditorCommands.cs @@ -23,7 +23,7 @@ public static object GetEditorState() { "sceneDirty", scene.isDirty }, { "unityVersion", Application.unityVersion }, { "platform", EditorUserBuildSettings.activeBuildTarget.ToString() }, - { "projectPath", Application.dataPath.Replace("/Assets", "") }, + { "projectPath", MCPAssetSafety.ProjectRoot.Replace('\\', '/') }, }; } diff --git a/Editor/MCPProjectCommands.cs b/Editor/MCPProjectCommands.cs index 398a481..640949a 100755 --- a/Editor/MCPProjectCommands.cs +++ b/Editor/MCPProjectCommands.cs @@ -12,7 +12,7 @@ public static class MCPProjectCommands { public static object GetInfo() { - string projectPath = Application.dataPath.Replace("/Assets", ""); + string projectPath = MCPAssetSafety.ProjectRoot.Replace('\\', '/'); // Get scenes in build settings var buildScenes = EditorBuildSettings.scenes diff --git a/Editor/MCPScriptCommands.cs b/Editor/MCPScriptCommands.cs index 97c5df0..fa705c1 100755 --- a/Editor/MCPScriptCommands.cs +++ b/Editor/MCPScriptCommands.cs @@ -58,11 +58,13 @@ public static object Update(Dictionary args) { string path = args.ContainsKey("path") ? args["path"].ToString() : ""; - // Update REQUIRES content: an unvalidated empty content used to truncate the - // target source file to zero bytes and import the wreckage. - if (!args.ContainsKey("content") || args["content"] == null) - return new { error = "content is required" }; - string content = args["content"].ToString(); + // Update REQUIRES non-empty content: an unvalidated empty/missing content used to + // truncate the target source file to zero bytes and import the wreckage. Empty + // string is the most likely accidental shape (a templating var that resolved to ""), + // so reject it like Create does — clearing a file must be deliberate, not a default. + string content = (args.ContainsKey("content") ? args["content"]?.ToString() : null); + if (string.IsNullOrEmpty(content)) + return new { error = "content is required (non-empty). To intentionally clear a file, write a single newline." }; if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) return new { error = pathError }; diff --git a/Editor/MCPShaderGraphApi.cs b/Editor/MCPShaderGraphApi.cs index 62a5d9d..45cea58 100644 --- a/Editor/MCPShaderGraphApi.cs +++ b/Editor/MCPShaderGraphApi.cs @@ -30,7 +30,7 @@ internal static class MCPShaderGraphApi private static Type _graphDataT, _targetT, _blockFieldT, _absNodeT, _slotRefT, _materialSlotT, _messageManagerT, _multiJsonT, _fileUtilT, _propertyNodeT; private static Type _urpTargetT, _litSubT, _unlitSubT, _spriteLitSubT, _spriteUnlitSubT; private static PropertyInfo _messageManagerProp, _objectIdProp, _drawStateProp, _edgesProp, _drawPositionProp; - private static MethodInfo _addContexts, _initOutputs, _getActiveBlocks, _addRemoveBlocks, _onEnable, _addNode, _getNodeFromId, _connect, _removeEdge, _getNodesGeneric, _getSlotsGeneric, _trySetSubTarget; + private static MethodInfo _addContexts, _initOutputs, _getActiveBlocks, _addRemoveBlocks, _onEnable, _addNode, _getNodeFromId, _connect, _removeEdge, _removeNode, _getNodesGeneric, _getSlotsGeneric, _trySetSubTarget; private static MethodInfo _serialize, _deserializeGeneric, _writeToDisk, _writeGraphToDisk; private static ConstructorInfo _slotRefCtor; @@ -92,19 +92,25 @@ private static void Initialize() m.GetParameters().Length == 1 && m.GetParameters()[0].ParameterType == typeof(string)); _connect = _graphDataT.GetMethod("Connect", PIF, null, new[] { _slotRefT, _slotRefT }, null); _removeEdge = _graphDataT.GetMethods(PIF).FirstOrDefault(m => m.Name == "RemoveEdge" && m.GetParameters().Length == 1); + _removeNode = _graphDataT.GetMethods(PIF).FirstOrDefault(m => + m.Name == "RemoveNode" && m.GetParameters().Length == 1 && m.GetParameters()[0].ParameterType == _absNodeT); _getNodesGeneric = _graphDataT.GetMethods(PIF).FirstOrDefault(m => m.Name == "GetNodes" && m.IsGenericMethod); _getSlotsGeneric = _absNodeT.GetMethods(BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(m => m.Name == "GetSlots" && m.IsGenericMethod && m.GetParameters().Length == 1); _trySetSubTarget = _urpTargetTryResolve(urp); _slotRefCtor = _slotRefT.GetConstructor(new[] { _absNodeT, typeof(int) }) ?? throw new InvalidOperationException("SlotReference ctor"); _serialize = _multiJsonT.GetMethod("Serialize", SF); - _deserializeGeneric = _multiJsonT.GetMethods(SF).FirstOrDefault(m => m.Name == "Deserialize" && m.IsGenericMethod); + // Pin the 4-arg Deserialize(T, string, JsonObject, bool) shape LoadGraph invokes; + // a different arity in some version would otherwise TargetParameterCountException at + // call time despite Available==true. Fail closed at init instead. + _deserializeGeneric = _multiJsonT.GetMethods(SF).FirstOrDefault(m => m.Name == "Deserialize" && m.IsGenericMethod && m.GetParameters().Length == 4); _writeToDisk = _fileUtilT.GetMethod("WriteToDisk", SF); _writeGraphToDisk = _fileUtilT.GetMethod("WriteShaderGraphToDisk", SF); if (_addContexts == null || _initOutputs == null || _getActiveBlocks == null || _addRemoveBlocks == null || _onEnable == null || _addNode == null || _getNodeFromId == null || _connect == null || _removeEdge == null || - _getNodesGeneric == null || _serialize == null || _deserializeGeneric == null || _writeToDisk == null || _writeGraphToDisk == null) + _removeNode == null || _getNodesGeneric == null || _serialize == null || _deserializeGeneric == null || + _writeToDisk == null || _writeGraphToDisk == null) throw new InvalidOperationException("One or more ShaderGraph API methods not found (version mismatch)"); } @@ -209,6 +215,20 @@ internal static string AddNode(object graph, Type nodeType, float x, float y) internal static object GetNodeFromId(object graph, string nodeId) => _getNodeFromId.Invoke(graph, new object[] { nodeId }); + /// Remove a node and its connected edges (byte-faithful to survivors). + internal static void RemoveNode(object graph, object node) => _removeNode.Invoke(graph, new object[] { node }); + + /// + /// True if the template names a specific pipeline target we can't build because that + /// pipeline's editor assembly isn't present (so create would silently fall back to blank). + /// + internal static bool IsUnavailablePipelineTemplate(string template) + { + string t = (template ?? "").ToLowerInvariant(); + bool wantsUrp = t.StartsWith("urp_") || t == "lit" || t == "unlit" || t.StartsWith("sprite_"); + return wantsUrp && !IsUrpAvailable; + } + /// Find a slot id on a node by input/output and (optional) explicit id. Returns int.MinValue if none. internal static int FindSlotId(object node, bool wantInput, int explicitId) { diff --git a/Editor/MCPShaderGraphCommands.cs b/Editor/MCPShaderGraphCommands.cs index f13656c..0ece035 100644 --- a/Editor/MCPShaderGraphCommands.cs +++ b/Editor/MCPShaderGraphCommands.cs @@ -389,20 +389,22 @@ public static object CreateShaderGraph(Dictionary args) if (!path.EndsWith(".shadergraph")) path += ".shadergraph"; - if (File.Exists(Path.Combine(Application.dataPath, "..", path))) - return new Dictionary { { "error", $"File already exists at: {path}" } }; - string template = args.ContainsKey("template") ? args["template"].ToString().ToLower() : "urp_lit"; if (!MCPShaderGraphApi.Available) return new Dictionary { { "error", "ShaderGraph real-API access unavailable: " + MCPShaderGraphApi.UnavailableReason } }; - // Resolve under the project root; guard against overwriting an existing graph. + // Don't silently downgrade an explicit URP template to blank when URP isn't installed. + if (MCPShaderGraphApi.IsUnavailablePipelineTemplate(template)) + return new Dictionary { { "error", $"Template '{template}' needs the URP Shader Graph package, which isn't installed. Use template 'blank' for a target-less graph." } }; + + // Resolve under the project root; guard against overwriting an existing graph + // (OverwriteGuard checks the canonical path + disk, so overwrite:true works). if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) return new Dictionary { { "error", pathError } }; var overwriteError = MCPAssetSafety.OverwriteGuard(path, args); if (overwriteError != null) - return overwriteError is Dictionary d ? d : new Dictionary { { "error", overwriteError.ToString() } }; + return overwriteError; try { @@ -779,54 +781,30 @@ public static object RemoveGraphNode(Dictionary args) string path = args["path"].ToString(); string nodeId = args["nodeId"].ToString(); - string fullPath = Path.Combine(Application.dataPath, "..", path); + if (!MCPShaderGraphApi.Available) + return new Dictionary { { "error", "ShaderGraph real-API access unavailable: " + MCPShaderGraphApi.UnavailableReason } }; + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; if (!File.Exists(fullPath)) return new Dictionary { { "error", $"File not found: {path}" } }; try { - string content = File.ReadAllText(fullPath); - - // Remove node reference from m_Nodes array - string refPattern = $"{{\"m_Id\":\"{nodeId}\"}}"; - content = content.Replace("," + refPattern, ""); - content = content.Replace(refPattern + ",", ""); - content = content.Replace(refPattern, ""); - - // Remove the node's JSON block (MultiJson format) - var blocks = ParseMultiJson(content); - var newBlocks = new List(); - int removedEdges = 0; - - foreach (var block in blocks) - { - string blockId = ExtractJsonString(block, "m_ObjectId") ?? ExtractJsonString(block, "m_Id"); - - // Skip the node itself - if (blockId == nodeId) continue; - - // For the main graph block, also remove edges referencing this node - if (block.Contains("\"m_Edges\"")) - { - string cleaned = RemoveEdgesForNode(block, nodeId, out removedEdges); - newBlocks.Add(cleaned); - } - else - { - newBlocks.Add(block); - } - } - - string newContent = string.Join("\n\n", newBlocks); - File.WriteAllText(fullPath, newContent); - AssetDatabase.ImportAsset(path); + // Real GraphData.RemoveNode also removes the node's connected edges and + // leaves every survivor byte-faithful — the old regex path blanked + // surviving multi-line edges to m_Id:"" and left a dangling node ref. + var graph = MCPShaderGraphApi.LoadGraph(fullPath); + var node = MCPShaderGraphApi.GetNodeFromId(graph, nodeId); + if (node == null) + return new Dictionary { { "error", $"Node not found: {nodeId}" } }; + MCPShaderGraphApi.RemoveNode(graph, node); + MCPShaderGraphApi.SaveGraph(MCPAssetSafety.ToAssetDatabasePath(path), graph); return new Dictionary { { "success", true }, { "removedNodeId", nodeId }, - { "removedEdges", removedEdges }, { "assetPath", path }, }; } @@ -959,7 +937,9 @@ public static object SetGraphNodeProperty(Dictionary args) string propertyName = args["propertyName"].ToString(); string value = args.ContainsKey("value") ? args["value"].ToString() : ""; - string fullPath = Path.Combine(Application.dataPath, "..", path); + // Confine the write under the project root (traversal/absolute-escape guard). + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; if (!File.Exists(fullPath)) return new Dictionary { { "error", $"File not found: {path}" } }; @@ -992,7 +972,7 @@ public static object SetGraphNodeProperty(Dictionary args) string newContent = string.Join("\n\n", newBlocks); File.WriteAllText(fullPath, newContent); - AssetDatabase.ImportAsset(path); + AssetDatabase.ImportAsset(MCPAssetSafety.ToAssetDatabasePath(path)); return new Dictionary { diff --git a/package.json b/package.json index f845496..c8c3bfe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.anklebreaker.unity-mcp", - "version": "2.33.0", + "version": "2.34.0", "displayName": "AnkleBreaker Unity MCP", "description": "MCP (Model Context Protocol) bridge plugin for Unity Editor. Enables AI assistants like Claude to control Unity Editor via a local HTTP API — manage scenes, GameObjects, components, assets, builds, and more.", "unity": "2021.3", From a383483951f15121a2ed98149594db2cf449a6c2 Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Sat, 18 Jul 2026 22:00:47 +0200 Subject: [PATCH 07/15] [FEAT] ProBuilder : - Full com.unity.probuilder integration: 14 probuilder/* handlers (MCPProBuilderCommands) - create-shape (cube/plane/cylinder/prism/stair/cone/door/pipe/arch/sphere/torus), extrude/bevel/subdivide/delete/translate/flip-normals faces, set-face-material, boolean CSG (union/subtract/intersect), combine, probuilderize, center-pivot, export-mesh - Gated on PROBUILDER_INSTALLED (asmdef versionDefine >= 4.0.0); clear not-installed stubs when absent, matching the UMA optional-integration pattern - Every mutation registers Undo so ProBuilder edits compose with the multi-agent queue; export-mesh uses outputPath and is confined under Assets/ via MCPAssetSafety - New probuilder capability category; route registry regenerated (336 routes) - Bump package 2.34.0 -> 2.35.0 --- CHANGELOG.md | 12 + Editor/AnkleBreaker.UnityMCP.Editor.asmdef | 13 +- Editor/MCPBridgeServer.Routes.g.cs | 16 +- Editor/MCPBridgeServer.cs | 30 ++ Editor/MCPProBuilderCommands.cs | 488 +++++++++++++++++++++ Editor/MCPProBuilderCommands.cs.meta | 11 + Editor/MCPSettingsManager.cs | 2 +- package.json | 2 +- 8 files changed, 569 insertions(+), 5 deletions(-) create mode 100644 Editor/MCPProBuilderCommands.cs create mode 100644 Editor/MCPProBuilderCommands.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6d2a1..9fa0625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this package will be documented in this file. +## [2.35.0] - 2026-07-18 + +### Added (ProBuilder integration — `com.unity.probuilder`) +- **Full ProBuilder command surface** (`MCPProBuilderCommands`, 14 handlers under the `probuilder/*` routes) — create parametric shapes and edit real, still-editable ProBuilder meshes through ProBuilder's public API: + - `create-shape` — cube, plane, cylinder, prism, stair, cone, door, pipe, arch, sphere (icosphere), torus, with per-shape parameters (sides, steps, thickness, angle, subdivisions, …). + - Geometry edits — `extrude-faces` (face/individual/vertex-normal), `bevel-edges`, `subdivide`, `delete-faces`, `translate-faces`, `flip-normals`. + - Materials — `set-face-material` (per-face submesh assignment). + - Boolean CSG — `boolean` (union / subtract / intersect); the result is a new editable ProBuilder object. + - `combine` (merge ProBuilder meshes), `probuilderize` (convert a plain mesh to ProBuilder), `center-pivot`, `export-mesh` (bake to a `.asset`, guarded by the shared overwrite check). +- **Tolerated-when-missing integration** — the whole surface is gated on `PROBUILDER_INSTALLED` (asmdef `versionDefines` on `com.unity.probuilder >= 4.0.0`); when ProBuilder is absent every handler returns a clear "not installed" message instead of failing to compile, matching the existing UMA pattern. New `probuilder` capability category in `MCPSettingsManager`. +- Every mutation registers Undo (`Undo.RegisterCompleteObjectUndo` / `RegisterCreatedObjectUndo`), so ProBuilder edits compose with the multi-agent queue's per-action undo tracking. `export-mesh` uses `outputPath` (distinct from the `path`/`instanceId` used to resolve the source object) and confines writes under `Assets/` via `MCPAssetSafety`. + ## [2.34.0] - 2026-07-18 ### Fixed (data safety — several handlers could silently destroy user assets) diff --git a/Editor/AnkleBreaker.UnityMCP.Editor.asmdef b/Editor/AnkleBreaker.UnityMCP.Editor.asmdef index f851fe2..555f8e4 100755 --- a/Editor/AnkleBreaker.UnityMCP.Editor.asmdef +++ b/Editor/AnkleBreaker.UnityMCP.Editor.asmdef @@ -5,7 +5,10 @@ "UnityEngine.TestRunner", "UnityEditor.TestRunner", "UMA_Core", - "UMA_Core_Editor" + "UMA_Core_Editor", + "Unity.ProBuilder", + "Unity.ProBuilder.Editor", + "Unity.ProBuilder.Csg" ], "includePlatforms": [ "Editor" @@ -16,6 +19,12 @@ "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], - "versionDefines": [], + "versionDefines": [ + { + "name": "com.unity.probuilder", + "expression": "4.0.0", + "define": "PROBUILDER_INSTALLED" + } + ], "noEngineReferences": false } \ No newline at end of file diff --git a/Editor/MCPBridgeServer.Routes.g.cs b/Editor/MCPBridgeServer.Routes.g.cs index 297b052..8280613 100644 --- a/Editor/MCPBridgeServer.Routes.g.cs +++ b/Editor/MCPBridgeServer.Routes.g.cs @@ -9,7 +9,7 @@ namespace UnityMCP.Editor { public static partial class MCPBridgeServer { - /// Every route the bridge can dispatch (322 routes). + /// Every route the bridge can dispatch (336 routes). internal static readonly string[] GeneratedRoutes = new string[] { "_meta/routes", @@ -183,6 +183,20 @@ public static partial class MCPBridgeServer "prefab/set-active", "prefab/set-object-reference", "prefab/unpack", + "probuilder/bevel-edges", + "probuilder/boolean", + "probuilder/center-pivot", + "probuilder/combine", + "probuilder/create-shape", + "probuilder/delete-faces", + "probuilder/export-mesh", + "probuilder/extrude-faces", + "probuilder/flip-normals", + "probuilder/info", + "probuilder/probuilderize", + "probuilder/set-face-material", + "probuilder/subdivide", + "probuilder/translate-faces", "profiler/analyze", "profiler/enable", "profiler/frame-data", diff --git a/Editor/MCPBridgeServer.cs b/Editor/MCPBridgeServer.cs index 413c616..4fa7124 100644 --- a/Editor/MCPBridgeServer.cs +++ b/Editor/MCPBridgeServer.cs @@ -1082,6 +1082,36 @@ private static object RouteRequest(string path, string method, string body) case "undo/clear": return MCPUndoCommands.ClearUndo(ParseJson(body)); + // ─── ProBuilder ─── + case "probuilder/create-shape": + return MCPProBuilderCommands.CreateShape(ParseJson(body)); + case "probuilder/info": + return MCPProBuilderCommands.GetInfo(ParseJson(body)); + case "probuilder/extrude-faces": + return MCPProBuilderCommands.ExtrudeFaces(ParseJson(body)); + case "probuilder/bevel-edges": + return MCPProBuilderCommands.BevelEdges(ParseJson(body)); + case "probuilder/subdivide": + return MCPProBuilderCommands.Subdivide(ParseJson(body)); + case "probuilder/delete-faces": + return MCPProBuilderCommands.DeleteFaces(ParseJson(body)); + case "probuilder/translate-faces": + return MCPProBuilderCommands.TranslateFaces(ParseJson(body)); + case "probuilder/flip-normals": + return MCPProBuilderCommands.FlipNormals(ParseJson(body)); + case "probuilder/set-face-material": + return MCPProBuilderCommands.SetFaceMaterial(ParseJson(body)); + case "probuilder/boolean": + return MCPProBuilderCommands.BooleanOp(ParseJson(body)); + case "probuilder/combine": + return MCPProBuilderCommands.Combine(ParseJson(body)); + case "probuilder/probuilderize": + return MCPProBuilderCommands.ProBuilderize(ParseJson(body)); + case "probuilder/center-pivot": + return MCPProBuilderCommands.CenterPivot(ParseJson(body)); + case "probuilder/export-mesh": + return MCPProBuilderCommands.ExportMesh(ParseJson(body)); + // ─── Screenshot / Scene View ─── case "screenshot/game": return MCPScreenshotCommands.CaptureGameView(ParseJson(body)); diff --git a/Editor/MCPProBuilderCommands.cs b/Editor/MCPProBuilderCommands.cs new file mode 100644 index 0000000..c7fea04 --- /dev/null +++ b/Editor/MCPProBuilderCommands.cs @@ -0,0 +1,488 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +#if PROBUILDER_INSTALLED +using System; +using System.Linq; +using UnityEngine.ProBuilder; +using UnityEngine.ProBuilder.MeshOperations; +using UnityEditor.ProBuilder; +#endif + +namespace UnityMCP.Editor +{ + /// + /// ProBuilder integration (com.unity.probuilder). Create and edit editable geometry — + /// shapes, extrude/bevel/subdivide, per-face materials, boolean ops, ProBuilderize — all + /// through ProBuilder's public API so the resulting meshes are real ProBuilder objects the + /// user can keep editing in the editor. Every mutation registers Undo, so it composes with + /// the multi-agent queue's per-action undo tracking. + /// + /// The whole surface is gated on PROBUILDER_INSTALLED (asmdef versionDefine). When ProBuilder + /// isn't in the project, every handler returns a clear "not installed" error instead of + /// failing to compile. + /// + public static class MCPProBuilderCommands + { +#if !PROBUILDER_INSTALLED + private static object NotInstalled() + { + return new Dictionary + { + { "error", "ProBuilder (com.unity.probuilder) is not installed. Install it via Package Manager to use probuilder/* tools." }, + { "hint", "Window > Package Manager > Unity Registry > ProBuilder > Install." }, + }; + } + + public static object CreateShape(Dictionary args) => NotInstalled(); + public static object GetInfo(Dictionary args) => NotInstalled(); + public static object ExtrudeFaces(Dictionary args) => NotInstalled(); + public static object BevelEdges(Dictionary args) => NotInstalled(); + public static object Subdivide(Dictionary args) => NotInstalled(); + public static object DeleteFaces(Dictionary args) => NotInstalled(); + public static object SetFaceMaterial(Dictionary args) => NotInstalled(); + public static object BooleanOp(Dictionary args) => NotInstalled(); + public static object ProBuilderize(Dictionary args) => NotInstalled(); + public static object Combine(Dictionary args) => NotInstalled(); + public static object TranslateFaces(Dictionary args) => NotInstalled(); + public static object FlipNormals(Dictionary args) => NotInstalled(); + public static object CenterPivot(Dictionary args) => NotInstalled(); + public static object ExportMesh(Dictionary args) => NotInstalled(); +#else + // ───────────────────────────────────────────── + // Create + // ───────────────────────────────────────────── + + public static object CreateShape(Dictionary args) + { + string shape = args.ContainsKey("shape") ? args["shape"].ToString().ToLowerInvariant() : "cube"; + float w = GetFloat(args, "width", 1f), h = GetFloat(args, "height", 1f), d = GetFloat(args, "depth", 1f); + var size = new Vector3(w, h, d); + + ProBuilderMesh pb; + switch (shape) + { + case "cube": pb = ShapeGenerator.GenerateCube(PivotLocation.Center, size); break; + case "plane": pb = ShapeGenerator.GeneratePlane(PivotLocation.Center, w, d, GetInt(args, "widthSegments", 1), GetInt(args, "lengthSegments", 1), Axis.Up); break; + case "cylinder": pb = ShapeGenerator.GenerateCylinder(PivotLocation.Center, GetInt(args, "sides", 8), Mathf.Max(w, d) * 0.5f, h, GetInt(args, "heightSegments", 0), 1); break; + case "prism": pb = ShapeGenerator.GeneratePrism(PivotLocation.Center, size); break; + case "stair": pb = ShapeGenerator.GenerateStair(PivotLocation.Center, size, GetInt(args, "steps", 6), GetBool(args, "sides", true)); break; + case "cone": pb = ShapeGenerator.GenerateCone(PivotLocation.Center, Mathf.Max(w, d) * 0.5f, h, GetInt(args, "sides", 8)); break; + case "door": pb = ShapeGenerator.GenerateDoor(PivotLocation.Center, w, h, GetFloat(args, "ledge", 0.15f), GetFloat(args, "legWidth", 0.25f), d); break; + case "pipe": pb = ShapeGenerator.GeneratePipe(PivotLocation.Center, Mathf.Max(w, d) * 0.5f, h, GetFloat(args, "thickness", 0.1f), GetInt(args, "sides", 8), GetInt(args, "heightSegments", 1)); break; + case "arch": pb = ShapeGenerator.GenerateArch(PivotLocation.Center, GetFloat(args, "angle", 180f), Mathf.Max(w, d) * 0.5f, GetFloat(args, "thickness", 0.2f), d, GetInt(args, "sides", 6), true, true, true, true, true); break; + case "sphere": + case "icosahedron": pb = ShapeGenerator.GenerateIcosahedron(PivotLocation.Center, Mathf.Max(w, h, d) * 0.5f, GetInt(args, "subdivisions", 2), true, true); break; + case "torus": pb = ShapeGenerator.GenerateTorus(PivotLocation.Center, GetInt(args, "rows", 12), GetInt(args, "columns", 16), Mathf.Max(w, d) * 0.5f, GetFloat(args, "tubeRadius", 0.2f), true, 360f, 360f, true); break; + default: + return Error($"Unknown shape '{shape}'. Supported: cube, plane, cylinder, prism, stair, cone, door, pipe, arch, sphere, torus."); + } + + var go = pb.gameObject; + go.name = args.ContainsKey("name") ? args["name"].ToString() : ("PB_" + shape); + if (args.ContainsKey("position") && args["position"] is Dictionary p) + go.transform.position = new Vector3(GetFloat(p, "x", 0), GetFloat(p, "y", 0), GetFloat(p, "z", 0)); + + if (args.ContainsKey("material")) + { + var mat = AssetDatabase.LoadAssetAtPath(args["material"].ToString()); + if (mat != null) pb.SetMaterial(pb.faces, mat); + } + + Rebuild(pb); + Undo.RegisterCreatedObjectUndo(go, "Create ProBuilder " + shape); + return Ok(pb, new Dictionary { { "shape", shape }, { "name", go.name } }); + } + + // ───────────────────────────────────────────── + // Inspect + // ───────────────────────────────────────────── + + public static object GetInfo(Dictionary args) + { + if (!TryResolve(args, out var pb, out var err)) return err; + var b = pb.GetComponent()?.sharedMesh != null ? pb.GetComponent().sharedMesh.bounds : new Bounds(); + var materials = pb.faces.Select(f => f.submeshIndex).Distinct().OrderBy(i => i).ToArray(); + return new Dictionary + { + { "success", true }, + { "name", pb.name }, + { "instanceId", MCPObjectId.Get(pb.gameObject) }, + { "isProBuilder", true }, + { "faceCount", pb.faceCount }, + { "vertexCount", pb.vertexCount }, + { "edgeCount", pb.edgeCount }, + { "sharedVertexCount", pb.sharedVertices.Count }, + { "submeshCount", materials.Length }, + { "bounds", new Dictionary { + { "center", V(b.center) }, { "size", V(b.size) } } }, + }; + } + + // ───────────────────────────────────────────── + // Geometry edits + // ───────────────────────────────────────────── + + public static object ExtrudeFaces(Dictionary args) + { + if (!TryResolve(args, out var pb, out var err)) return err; + var faces = ResolveFaces(pb, args, out var faceErr); + if (faceErr != null) return faceErr; + float distance = GetFloat(args, "distance", 0.5f); + var method = ParseExtrudeMethod(args); + + UndoRecord(pb, "Extrude Faces"); + var newFaces = ExtrudeElements.Extrude(pb, faces, method, distance); + Rebuild(pb); + return Ok(pb, new Dictionary { { "extrudedFaces", faces.Count }, { "newFaces", newFaces?.Length ?? 0 }, { "distance", distance } }); + } + + public static object BevelEdges(Dictionary args) + { + if (!TryResolve(args, out var pb, out var err)) return err; + var faces = ResolveFaces(pb, args, out var faceErr); + if (faceErr != null) return faceErr; + float amount = GetFloat(args, "amount", 0.1f); + // Bevel every edge of the selected faces (a face-driven bevel — the common case). + var edges = faces.SelectMany(f => f.edges).Distinct().ToList(); + + UndoRecord(pb, "Bevel Edges"); + Bevel.BevelEdges(pb, edges, amount); + Rebuild(pb); + return Ok(pb, new Dictionary { { "beveledEdges", edges.Count }, { "amount", amount } }); + } + + public static object Subdivide(Dictionary args) + { + if (!TryResolve(args, out var pb, out var err)) return err; + var faces = args.ContainsKey("faceIndices") ? ResolveFaces(pb, args, out var fe) : pb.faces.ToList(); + if (faces == null) return Error("Invalid faceIndices."); + + UndoRecord(pb, "Subdivide"); + ConnectElements.Connect(pb, faces); + Rebuild(pb); + return Ok(pb, new Dictionary { { "subdividedFaces", faces.Count } }); + } + + public static object DeleteFaces(Dictionary args) + { + if (!TryResolve(args, out var pb, out var err)) return err; + var faces = ResolveFaces(pb, args, out var faceErr); + if (faceErr != null) return faceErr; + if (faces.Count >= pb.faceCount) + return Error("Refusing to delete every face (that would empty the mesh). Delete a subset or delete the GameObject instead."); + + UndoRecord(pb, "Delete Faces"); + DeleteElements.DeleteFaces(pb, faces); + Rebuild(pb); + return Ok(pb, new Dictionary { { "deletedFaces", faces.Count } }); + } + + public static object TranslateFaces(Dictionary args) + { + if (!TryResolve(args, out var pb, out var err)) return err; + var faces = ResolveFaces(pb, args, out var faceErr); + if (faceErr != null) return faceErr; + var d = args.ContainsKey("translation") && args["translation"] is Dictionary t + ? new Vector3(GetFloat(t, "x", 0), GetFloat(t, "y", 0), GetFloat(t, "z", 0)) + : Vector3.zero; + + UndoRecord(pb, "Move Faces"); + var indexes = faces.SelectMany(f => f.distinctIndexes).Distinct(); + pb.TranslateVertices(faces.SelectMany(f => f.indexes).Distinct().Select(i => i).ToArray(), d); + Rebuild(pb); + return Ok(pb, new Dictionary { { "movedFaces", faces.Count }, { "translation", V(d) } }); + } + + public static object FlipNormals(Dictionary args) + { + if (!TryResolve(args, out var pb, out var err)) return err; + UndoRecord(pb, "Flip Normals"); + foreach (var f in pb.faces) f.Reverse(); + Rebuild(pb); + return Ok(pb, new Dictionary { { "flippedFaces", pb.faceCount } }); + } + + // ───────────────────────────────────────────── + // Materials + // ───────────────────────────────────────────── + + public static object SetFaceMaterial(Dictionary args) + { + if (!TryResolve(args, out var pb, out var err)) return err; + if (!args.ContainsKey("material")) return Error("material (asset path) is required."); + var mat = AssetDatabase.LoadAssetAtPath(args["material"].ToString()); + if (mat == null) return Error($"Material not found: {args["material"]}"); + var faces = args.ContainsKey("faceIndices") ? ResolveFaces(pb, args, out var fe) : pb.faces.ToList(); + if (faces == null) return Error("Invalid faceIndices."); + + UndoRecord(pb, "Set Face Material"); + pb.SetMaterial(faces, mat); + Rebuild(pb); + return Ok(pb, new Dictionary { { "material", mat.name }, { "faces", faces.Count } }); + } + + // ───────────────────────────────────────────── + // Boolean / combine / convert + // ───────────────────────────────────────────── + + public static object BooleanOp(Dictionary args) + { + string op = args.ContainsKey("operation") ? args["operation"].ToString().ToLowerInvariant() : "union"; + var a = ResolveGameObject(args, "targetPath", "targetInstanceId"); + var b = ResolveGameObject(args, "otherPath", "otherInstanceId"); + if (a == null || b == null) return Error("Both targetPath/targetInstanceId and otherPath/otherInstanceId must resolve to GameObjects."); + + string method; + switch (op) + { + case "union": method = "Union"; break; + case "subtract": case "difference": method = "Subtract"; break; + case "intersect": case "intersection": method = "Intersect"; break; + default: return Error($"Unknown boolean operation '{op}'. Use union, subtract, or intersect."); + } + // ProBuilder's CSG class is internal, so invoke it via reflection. + var mesh = InvokeCsg(method, a, b, out var csgErr); + if (mesh == null) return Error(csgErr ?? "Boolean operation produced no mesh (are both objects solid meshes?)."); + var result = mesh; + + var go = new GameObject($"PB_Boolean_{op}"); + go.transform.position = a.transform.position; + var mf = go.AddComponent(); + mf.sharedMesh = result; + var mr = go.AddComponent(); + var srcMat = a.GetComponent() != null ? a.GetComponent().sharedMaterial : null; + mr.sharedMaterial = srcMat; + // Make the boolean result editable in ProBuilder: add a ProBuilderMesh to the + // result object and import the generated mesh into it. Pass the CSG mesh directly — + // AddComponent nulls the MeshFilter's sharedMesh. + var pb = Undo.AddComponent(go); + var imported = MeshImporter_TryImport(pb, result, srcMat != null ? new[] { srcMat } : new Material[0]); + if (imported) Rebuild(pb); + Undo.RegisterCreatedObjectUndo(go, "ProBuilder Boolean"); + + return new Dictionary + { + { "success", true }, + { "operation", op }, + { "name", go.name }, + { "instanceId", MCPObjectId.Get(go) }, + { "vertexCount", result.vertexCount }, + { "editableProBuilder", imported }, + }; + } + + public static object Combine(Dictionary args) + { + if (!(args.ContainsKey("paths") && args["paths"] is List raw) || raw.Count < 2) + return Error("paths (array of >= 2 GameObject paths) is required."); + var meshes = new List(); + foreach (var o in raw) + { + var go = GameObject.Find(o.ToString()); + var pb = go != null ? go.GetComponent() : null; + if (pb == null) return Error($"'{o}' is not a ProBuilder object (ProBuilderize it first)."); + meshes.Add(pb); + } + var result = CombineMeshes.Combine(meshes, meshes[0]); + foreach (var pb in result) Rebuild(pb); + // ProBuilder merges into the first mesh and destroys the rest. + for (int i = 1; i < meshes.Count; i++) + if (meshes[i] != null) Undo.DestroyObjectImmediate(meshes[i].gameObject); + return Ok(meshes[0], new Dictionary { { "combined", raw.Count } }); + } + + public static object ProBuilderize(Dictionary args) + { + var go = ResolveGameObject(args, "path", "instanceId"); + if (go == null) return Error("GameObject not found."); + if (go.GetComponent() != null) return Error("GameObject is already a ProBuilder object."); + var mf = go.GetComponent(); + if (mf == null || mf.sharedMesh == null) return Error("GameObject has no MeshFilter/mesh to convert."); + // Capture source mesh + materials BEFORE adding ProBuilderMesh (which nulls sharedMesh). + var srcMesh = mf.sharedMesh; + var srcMats = go.GetComponent() != null ? go.GetComponent().sharedMaterials : new Material[0]; + + var pb = Undo.AddComponent(go); + bool ok = MeshImporter_TryImport(pb, srcMesh, srcMats); + if (!ok) { Undo.DestroyObjectImmediate(pb); return Error("Failed to import the mesh into ProBuilder."); } + Rebuild(pb); + return Ok(pb, new Dictionary { { "converted", true } }); + } + + // ───────────────────────────────────────────── + // Pivot / export + // ───────────────────────────────────────────── + + public static object CenterPivot(Dictionary args) + { + if (!TryResolve(args, out var pb, out var err)) return err; + UndoRecord(pb, "Center Pivot"); + pb.CenterPivot(null); + Rebuild(pb); + return Ok(pb, new Dictionary { { "centeredPivot", true } }); + } + + public static object ExportMesh(Dictionary args) + { + if (!TryResolve(args, out var pb, out var err)) return err; + // Distinct key from the 'path' used to RESOLVE the object, so an object identified by + // hierarchy path can still be exported to a chosen asset path. + if (!args.ContainsKey("outputPath") || args["outputPath"] == null) + return Error("outputPath (e.g. 'Assets/Meshes/MyMesh.asset') is required."); + string path = args["outputPath"].ToString(); + if (!path.EndsWith(".asset")) path += ".asset"; + if (!MCPAssetSafety.TryResolveProjectPath(path, out _, out var pathError)) return Error(pathError); + var over = MCPAssetSafety.OverwriteGuard(path, args); + if (over != null) return over; + + var mesh = UnityEngine.Object.Instantiate(pb.GetComponent().sharedMesh); + mesh.name = pb.name; + AssetDatabase.CreateAsset(mesh, MCPAssetSafety.ToAssetDatabasePath(path)); + AssetDatabase.SaveAssets(); + return new Dictionary { { "success", true }, { "assetPath", path }, { "vertexCount", mesh.vertexCount } }; + } + + // ───────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────── + + private static void Rebuild(ProBuilderMesh pb) + { + pb.ToMesh(); + pb.Refresh(); + UnityEditor.ProBuilder.EditorUtility.SynchronizeWithMeshFilter(pb); + } + + private static void UndoRecord(ProBuilderMesh pb, string msg) + { + // Public Undo API — records the ProBuilderMesh so an undo restores geometry and + // composes with the queue's per-action undo-group tracking. + Undo.RegisterCompleteObjectUndo(pb, msg); + } + + /// Invoke ProBuilder's internal CSG.Union/Subtract/Intersect via reflection. + private static Mesh InvokeCsg(string method, GameObject a, GameObject b, out string error) + { + error = null; + try + { + var csg = System.AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(asm => { try { return asm.GetTypes(); } catch { return new System.Type[0]; } }) + .FirstOrDefault(t => t.Name == "CSG" && t.Namespace != null && t.Namespace.Contains("ProBuilder")); + if (csg == null) { error = "ProBuilder CSG type not found."; return null; } + var mi = csg.GetMethod(method, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static, + null, new[] { typeof(GameObject), typeof(GameObject) }, null); + if (mi == null) { error = $"ProBuilder CSG.{method} not found."; return null; } + // CSG.Union/Subtract/Intersect return a Csg.Model; its .mesh property is the built Mesh. + var modelObj = mi.Invoke(null, new object[] { a, b }); + if (modelObj == null) { error = "Boolean operation returned no result."; return null; } + var meshProp = modelObj.GetType().GetProperty("mesh"); + var mesh = meshProp != null ? meshProp.GetValue(modelObj) as Mesh : null; + if (mesh == null) { error = "Boolean result had no mesh."; return null; } + return mesh; + } + catch (System.Exception ex) { error = "Boolean op failed: " + (ex.InnerException ?? ex).Message; return null; } + } + + private static bool TryResolve(Dictionary args, out ProBuilderMesh pb, out object error) + { + pb = null; error = null; + var go = ResolveGameObject(args, "path", "instanceId"); + if (go == null) { error = Error("GameObject not found (pass 'path' or 'instanceId')."); return false; } + pb = go.GetComponent(); + if (pb == null) { error = Error($"'{go.name}' is not a ProBuilder object. Use probuilder/probuilderize first."); return false; } + return true; + } + + private static GameObject ResolveGameObject(Dictionary args, string pathKey, string idKey) + { + if (args.ContainsKey(idKey) && args[idKey] != null) + { + var obj = MCPObjectId.ToObject(args[idKey]); + if (obj is GameObject g) return g; + if (obj is Component c) return c.gameObject; + } + if (args.ContainsKey(pathKey) && args[pathKey] != null) + return GameObject.Find(args[pathKey].ToString()); + return null; + } + + private static List ResolveFaces(ProBuilderMesh pb, Dictionary args, out object error) + { + error = null; + if (!args.ContainsKey("faceIndices") || !(args["faceIndices"] is List raw)) + { + error = Error("faceIndices (array of face indices) is required. Use probuilder/info for the face count."); + return null; + } + var faces = new List(); + foreach (var o in raw) + { + if (!int.TryParse(o.ToString(), out int idx) || idx < 0 || idx >= pb.faceCount) + { error = Error($"Face index out of range: {o} (mesh has {pb.faceCount} faces)."); return null; } + faces.Add(pb.faces[idx]); + } + if (faces.Count == 0) { error = Error("faceIndices is empty."); return null; } + return faces; + } + + /// + /// Import a source mesh + materials into . The mesh and materials + /// MUST be captured by the caller BEFORE adding the ProBuilderMesh component — adding it + /// resets the MeshFilter's sharedMesh to an empty mesh, so reading it afterwards yields null. + /// + private static bool MeshImporter_TryImport(ProBuilderMesh pb, Mesh sourceMesh, Material[] mats) + { + if (sourceMesh == null) return false; + try + { + var importer = new MeshImporter(sourceMesh, mats ?? new Material[0], pb); + importer.Import(new MeshImportSettings { quads = true, smoothing = true, smoothingAngle = 30f }); + return true; + } + catch { return false; } + } + + private static ExtrudeMethod ParseExtrudeMethod(Dictionary args) + { + string m = args.ContainsKey("method") ? args["method"].ToString().ToLowerInvariant() : "faceNormal"; + switch (m) + { + case "individual": case "individualfaces": return ExtrudeMethod.IndividualFaces; + case "vertexnormal": return ExtrudeMethod.VertexNormal; + default: return ExtrudeMethod.FaceNormal; + } + } + + private static object Ok(ProBuilderMesh pb, Dictionary extra) + { + var d = new Dictionary + { + { "success", true }, + { "name", pb.name }, + { "instanceId", MCPObjectId.Get(pb.gameObject) }, + { "faceCount", pb.faceCount }, + { "vertexCount", pb.vertexCount }, + }; + foreach (var kv in extra) d[kv.Key] = kv.Value; + return d; + } + + private static Dictionary V(Vector3 v) => + new Dictionary { { "x", v.x }, { "y", v.y }, { "z", v.z } }; +#endif + + // ─── Shared arg helpers (available in both branches) ─── + private static object Error(string msg) => new Dictionary { { "error", msg } }; + + private static float GetFloat(Dictionary a, string k, float def) => + a != null && a.ContainsKey(k) && a[k] != null && float.TryParse(a[k].ToString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var f) ? f : def; + + private static int GetInt(Dictionary a, string k, int def) => + a != null && a.ContainsKey(k) && a[k] != null && int.TryParse(a[k].ToString(), out var i) ? i : def; + + private static bool GetBool(Dictionary a, string k, bool def) => + a != null && a.ContainsKey(k) && a[k] != null && (a[k].ToString().ToLowerInvariant() == "true" || a[k].ToString() == "1") ? true : (a != null && a.ContainsKey(k) && a[k] != null ? false : def); + } +} diff --git a/Editor/MCPProBuilderCommands.cs.meta b/Editor/MCPProBuilderCommands.cs.meta new file mode 100644 index 0000000..cae164e --- /dev/null +++ b/Editor/MCPProBuilderCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1b32080e54cb485c92b0ea6b4f78a436 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MCPSettingsManager.cs b/Editor/MCPSettingsManager.cs index df083c3..34522f9 100644 --- a/Editor/MCPSettingsManager.cs +++ b/Editor/MCPSettingsManager.cs @@ -109,7 +109,7 @@ private static void MigrateString(string name, string prefix) "amplify", "animation", "asmdef", "asset", "audio", "build", "component", "console", "constraint", "debugger", "editor", "gameobject", "graphics", "input", "lighting", "memoryprofiler", "mppm", "navigation", "packagemanager", "particle", "physics", "prefab", - "prefabasset", "prefs", "profiler", "project", "projectsettings", "renderer", + "prefabasset", "prefs", "probuilder", "profiler", "project", "projectsettings", "renderer", "scenario", "scene", "screenshot", "script", "scriptableobject", "search", "selection", "shadergraph", "spriteatlas", "taglayer", "terrain", "testing", "texture", "ui", "uma", "undo" diff --git a/package.json b/package.json index c8c3bfe..1e725d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.anklebreaker.unity-mcp", - "version": "2.34.0", + "version": "2.35.0", "displayName": "AnkleBreaker Unity MCP", "description": "MCP (Model Context Protocol) bridge plugin for Unity Editor. Enables AI assistants like Claude to control Unity Editor via a local HTTP API — manage scenes, GameObjects, components, assets, builds, and more.", "unity": "2021.3", From e29e9431ad6e463e2c4fcea5f697c1fb17604fe3 Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Sat, 18 Jul 2026 22:29:40 +0200 Subject: [PATCH 08/15] [FEAT] Undo : - Per-action / per-agent undo: new undo/last reverts the most recent undoable MCP action as one whole step; agentId targets a specific agent's last action - Honest about Unity's linear undo: undo/last refuses to cascade and lists collateral (newer actions stacked on top) unless force:true - undo/history is now a per-agent action log (newest-first, agentId/count filters, undoable flags, current group) instead of only the current group name [REFACTO] MCPRequestQueue : - Wrap every WRITE action in its own named, collapsed Undo group so each agent's action is independently revertable and named in Unity's Undo history - Detect real undoability from the actual stack via internal Undo.GetRecords (old GetCurrentGroup before/after diff missed most edits e.g. RegisterCreatedObjectUndo); reads, undo-ops, empty groups, failures and execute-code (incidental temp-host churn) stay non-undoable. Fails open if the internal API is unavailable - Register undo/last route; regenerate route registry (337 routes) - Bump package 2.35.0 -> 2.36.0 --- CHANGELOG.md | 10 +++ Editor/MCPBridgeServer.Routes.g.cs | 3 +- Editor/MCPBridgeServer.cs | 2 + Editor/MCPRequestQueue.cs | 82 +++++++++++++++++- Editor/MCPUndoCommands.cs | 132 +++++++++++++++++++++++++++-- package.json | 2 +- 6 files changed, 217 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa0625..ebe76ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this package will be documented in this file. +## [2.36.0] - 2026-07-18 + +### Added (per-action / per-agent Undo) +- **`undo/last` — revert the most recent undoable MCP action as a whole.** Each write action now reverts cleanly as one step (a whole create/edit/boolean, not one internal operation). With `agentId`, targets that agent's most recent action. Honest about Unity's LINEAR undo: if newer actions are stacked on top of the target, `undo/last` refuses to cascade and lists exactly which actions would also be reverted unless `force:true` is passed. +- **`undo/history` is now a real per-agent action log** — returns recent MCP actions (newest first, optional `agentId`/`count` filters) with per-agent attribution, target object, an `undoable` flag, and the current Unity undo group — instead of only the current group name. + +### Changed (multi-agent queue — each action is now independently revertable) +- **The queue wraps every WRITE action in its own named, collapsed Undo group** (`MCPRequestQueue`). Previously the recorded undo group used a `GetCurrentGroup()` before/after diff that missed most real edits (e.g. `RegisterCreatedObjectUndo` doesn't advance the group), so per-action undo was effectively unavailable. Now each agent's action is a clean, named entry in Unity's Undo history and a precise revert target for `undo/last`. +- **Undoability is detected from the actual undo stack**, not guessed: an action is only marked undoable if it genuinely registered an undo op (measured via the internal `Undo.GetRecords`), so reads that slip past the read classifier — and `execute-code`, whose temp-host churn registers incidental undo — never become misleading `undo/last` targets. Reads, undo/redo ops, empty groups and failures stay non-undoable. Fails open if the internal API is ever unavailable. + ## [2.35.0] - 2026-07-18 ### Added (ProBuilder integration — `com.unity.probuilder`) diff --git a/Editor/MCPBridgeServer.Routes.g.cs b/Editor/MCPBridgeServer.Routes.g.cs index 8280613..1be805f 100644 --- a/Editor/MCPBridgeServer.Routes.g.cs +++ b/Editor/MCPBridgeServer.Routes.g.cs @@ -9,7 +9,7 @@ namespace UnityMCP.Editor { public static partial class MCPBridgeServer { - /// Every route the bridge can dispatch (336 routes). + /// Every route the bridge can dispatch (337 routes). internal static readonly string[] GeneratedRoutes = new string[] { "_meta/routes", @@ -346,6 +346,7 @@ public static partial class MCPBridgeServer "uma/wardrobe-equip", "undo/clear", "undo/history", + "undo/last", "undo/perform", "undo/redo", }; diff --git a/Editor/MCPBridgeServer.cs b/Editor/MCPBridgeServer.cs index 4fa7124..60b45f5 100644 --- a/Editor/MCPBridgeServer.cs +++ b/Editor/MCPBridgeServer.cs @@ -1075,6 +1075,8 @@ private static object RouteRequest(string path, string method, string body) // ─── Undo ─── case "undo/perform": return MCPUndoCommands.PerformUndo(ParseJson(body)); + case "undo/last": + return MCPUndoCommands.UndoLast(ParseJson(body)); case "undo/redo": return MCPUndoCommands.PerformRedo(ParseJson(body)); case "undo/history": diff --git a/Editor/MCPRequestQueue.cs b/Editor/MCPRequestQueue.cs index f3543e0..34d7a92 100644 --- a/Editor/MCPRequestQueue.cs +++ b/Editor/MCPRequestQueue.cs @@ -282,8 +282,25 @@ public static void ProcessNextRequests() // --- Execute OUTSIDE lock (main thread) --- foreach (var ticket in batch) { - // Capture undo group before execution for undo support - int undoGroupBefore = UnityEditor.Undo.GetCurrentGroup(); + // Give each WRITE action its own named, collapsed Undo group so it can be + // reverted independently (per-action / per-agent undo via undo/last) and shows + // up named in Unity's Undo history. Reads and undo/redo ops don't open a group, + // so they never clutter the history or shift group indices out from under a + // pending undo. GetCurrentGroup() alone is unreliable — many write ops (e.g. + // RegisterCreatedObjectUndo) don't advance it — so we open the group explicitly. + bool opensUndoGroup = + !IsReadOperation(ticket.ActionName) + && !(ticket.ActionName != null && ticket.ActionName.StartsWith("undo/")); + int undoGroup = -1; + int undoRecordsBefore = -1; + if (opensUndoGroup) + { + undoRecordsBefore = CountUndoRecords(); + UnityEditor.Undo.IncrementCurrentGroup(); + undoGroup = UnityEditor.Undo.GetCurrentGroup(); + UnityEditor.Undo.SetCurrentGroupName(ticket.ActionName ?? "MCP Action"); + } + int deferredUndoGroup = undoGroup; // stable capture for the deferred closure // Deferred actions complete via callback on a future editor frame. if (ticket.DeferredAction != null) @@ -297,6 +314,8 @@ public static void ProcessNextRequests() deferredTicket.Status = RequestStatus.Completed; deferredTicket.CompletedAt = DateTime.UtcNow; deferredTicket.DeferredAction = null; + if (deferredUndoGroup >= 0) + UnityEditor.Undo.CollapseUndoOperations(deferredUndoGroup); lock (_queueLock) { @@ -342,7 +361,23 @@ public static void ProcessNextRequests() ticket.CompletedAt = DateTime.UtcNow; ticket.Action = null; // Free the closure - int undoGroupAfter = UnityEditor.Undo.GetCurrentGroup(); + // Fold everything this action registered into its single named group so one + // undo/last (or a native Ctrl+Z) reverts the whole action as one step. + if (undoGroup >= 0) + UnityEditor.Undo.CollapseUndoOperations(undoGroup); + + // An action is undoable only if it ACTUALLY registered an undo op. Reads that + // slip past IsReadOperation, and execute-code that only inspects, open an empty + // group we must not offer as an undo target (reverting it would be a confusing + // no-op). Fail open: if the internal record-count API is unavailable, keep the + // group (old behavior). We can only prove "empty" when both counts are valid. + bool didRegisterUndo = undoGroup >= 0; + if (didRegisterUndo && undoRecordsBefore >= 0) + { + int undoRecordsAfter = CountUndoRecords(); + if (undoRecordsAfter >= 0 && undoRecordsAfter <= undoRecordsBefore) + didRegisterUndo = false; + } // Record action in history try @@ -356,7 +391,14 @@ public static void ProcessNextRequests() Status = ticket.Status.ToString(), ExecutionTimeMs = ticket.ExecutionTimeMs, ErrorMessage = ticket.ErrorMessage, - UndoGroup = undoGroupAfter != undoGroupBefore ? undoGroupBefore : -1, + // Only a completed write action that registered an undo op is undoable; + // its dedicated group is the revert target for undo/last. Reads, empty + // groups, undo-ops and failures stay -1. execute-code is excluded too: + // it's an introspection escape hatch whose temp-host churn registers undo + // but shouldn't shadow the agent's real edits as an undo/last target + // (its group still isolates that churn; native Ctrl+Z still reaches it). + UndoGroup = (ticket.Status == RequestStatus.Completed && didRegisterUndo + && ticket.ActionName != "editor/execute-code") ? undoGroup : -1, }; // Try to extract target object info from result @@ -596,6 +638,38 @@ private static bool IsReadOperation(string actionName) || lower.Contains("/status"); } + // Cached reflection for UnityEditor.Undo.GetRecords(List, List) — the + // internal API the Undo History window uses. Lets us tell whether an action actually + // put something on the undo stack (see the undo-group logic in ProcessNextRequests). + private static System.Reflection.MethodInfo _getUndoRecords; + private static bool _getUndoRecordsResolved; + private static readonly List _undoScratchU = new List(); + private static readonly List _undoScratchR = new List(); + + /// Current undo-stack depth, or -1 if the internal API is unavailable. + private static int CountUndoRecords() + { + try + { + if (!_getUndoRecordsResolved) + { + _getUndoRecords = typeof(UnityEditor.Undo).GetMethod( + "GetRecords", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static, + null, + new[] { typeof(List), typeof(List) }, + null); + _getUndoRecordsResolved = true; + } + if (_getUndoRecords == null) return -1; + _undoScratchU.Clear(); + _undoScratchR.Clear(); + _getUndoRecords.Invoke(null, new object[] { _undoScratchU, _undoScratchR }); + return _undoScratchU.Count; + } + catch { return -1; } + } + private static void PurgeEmptyQueues() { for (int i = _rrOrder.Count - 1; i >= 0; i--) diff --git a/Editor/MCPUndoCommands.cs b/Editor/MCPUndoCommands.cs index 677705c..3a4d099 100644 --- a/Editor/MCPUndoCommands.cs +++ b/Editor/MCPUndoCommands.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using UnityEditor; using UnityEngine; @@ -7,18 +8,29 @@ namespace UnityMCP.Editor { /// /// Commands for interacting with the Unity Undo system. + /// + /// The queue () wraps every WRITE action in its own named, + /// collapsed Undo group and records that group on the action's . + /// That makes per-action / per-agent undo possible: reverts a specific + /// recorded action by its group. + /// + /// Honesty about Unity's model: the Undo stack is GLOBAL and LINEAR. Reverting a group reverts + /// that group AND everything stacked on top of it — you cannot cherry-pick one action out of + /// the middle. UndoLast surfaces exactly which later actions would be caught in the revert and + /// refuses to cascade unless the caller passes force:true. /// public static class MCPUndoCommands { - // ─── Undo ─── + // ─── Undo (single global step) ─── public static object PerformUndo(Dictionary args) { + string name = Undo.GetCurrentGroupName(); Undo.PerformUndo(); return new Dictionary { { "success", true }, - { "message", "Undo performed" }, + { "message", string.IsNullOrEmpty(name) ? "Undo performed" : $"Undo performed: {name}" }, }; } @@ -34,17 +46,91 @@ public static object PerformRedo(Dictionary args) }; } - // ─── Get Undo History ─── + // ─── Undo Last MCP Action (per-agent aware) ─── + + /// + /// Revert the most recent undoable MCP action. With agentId, targets that agent's + /// most recent action instead. Because Unity's undo is linear, if later actions are stacked + /// on top of the target they are reverted too — this is reported and requires force:true. + /// + public static object UndoLast(Dictionary args) + { + string agentId = GetString(args, "agentId"); + bool force = GetBool(args, "force"); + + // Undoable = a completed write action that opened its own undo group. + var undoable = MCPActionHistory.GetAll() + .Where(r => r.UndoGroup >= 0 && r.Status == "Completed") + .ToList(); + if (undoable.Count == 0) + return Err("No undoable action recorded yet. (Reads and failed actions are never undoable.)"); + + // Newest undoable action overall, or the newest for the given agent. + MCPActionRecord target = string.IsNullOrEmpty(agentId) + ? undoable[undoable.Count - 1] + : undoable.LastOrDefault(r => string.Equals(r.AgentId, agentId, StringComparison.OrdinalIgnoreCase)); + if (target == null) + return Err($"No undoable action recorded for agent '{agentId}'."); + + // Linear model: reverting target also reverts every action stacked above it + // (a strictly higher undo group). Surface them before cascading. + var collateral = undoable + .Where(r => r.Id != target.Id && r.UndoGroup > target.UndoGroup) + .OrderByDescending(r => r.UndoGroup) + .ToList(); + + if (collateral.Count > 0 && !force) + { + return new Dictionary + { + { "error", $"Unity's undo is linear: reverting '{target.ActionName}' would also revert {collateral.Count} newer action(s) stacked on top of it. Pass force:true to revert them all, or undo the most recent action instead (omit agentId)." }, + { "target", Describe(target) }, + { "wouldAlsoRevert", collateral.Select(Describe).ToList() }, + }; + } + + // Perform the revert down to (and including) the target's group. + Undo.RevertAllDownToGroup(target.UndoGroup); + + // The reverted records are no longer on the stack — mark them so history and a + // follow-up undo/last reflect reality (GetAll returns the live record instances). + var reverted = new List { target }; + reverted.AddRange(collateral); + foreach (var r in reverted) r.UndoGroup = -1; + + return new Dictionary + { + { "success", true }, + { "message", collateral.Count > 0 + ? $"Reverted '{target.ActionName}' and {collateral.Count} newer action(s)." + : $"Reverted '{target.ActionName}'." }, + { "revertedCount", reverted.Count }, + { "reverted", reverted.Select(Describe).ToList() }, + }; + } + + // ─── Undo History (enriched with the per-agent action log) ─── public static object GetUndoHistory(Dictionary args) { - Undo.GetCurrentGroupName(); - int currentGroup = Undo.GetCurrentGroup(); + int count = Math.Max(1, GetInt(args, "count", 20)); + string agentId = GetString(args, "agentId"); + + var all = MCPActionHistory.GetAll(); + IEnumerable query = all; + if (!string.IsNullOrEmpty(agentId)) + query = query.Where(r => string.Equals(r.AgentId, agentId, StringComparison.OrdinalIgnoreCase)); + + // Newest first, capped at count. + var recent = query.Reverse().Take(count).ToList(); return new Dictionary { + { "currentGroup", Undo.GetCurrentGroup() }, { "currentGroupName", Undo.GetCurrentGroupName() }, - { "currentGroup", currentGroup }, + { "totalActions", all.Count }, + { "undoableCount", all.Count(r => r.UndoGroup >= 0 && r.Status == "Completed") }, + { "actions", recent.Select(Describe).ToList() }, }; } @@ -52,7 +138,7 @@ public static object GetUndoHistory(Dictionary args) public static object ClearUndo(Dictionary args) { - if (args.ContainsKey("objectPath")) + if (args != null && args.ContainsKey("objectPath")) { var go = GameObject.Find(args["objectPath"].ToString()); if (go != null) @@ -64,7 +150,7 @@ public static object ClearUndo(Dictionary args) { "message", $"Cleared undo for '{go.name}'" }, }; } - return new { error = "GameObject not found" }; + return Err("GameObject not found"); } Undo.ClearAll(); @@ -74,5 +160,35 @@ public static object ClearUndo(Dictionary args) { "message", "All undo history cleared" }, }; } + + // ─── Helpers ─── + + private static Dictionary Describe(MCPActionRecord r) => + new Dictionary + { + { "id", r.Id }, + { "agentId", r.AgentId }, + { "action", r.ActionName }, + { "category", r.Category }, + { "target", r.TargetPath ?? r.TargetInstanceId }, + { "status", r.Status }, + { "undoable", r.UndoGroup >= 0 && r.Status == "Completed" }, + { "timestamp", r.Timestamp.ToString("o") }, + }; + + private static object Err(string msg) => new Dictionary { { "error", msg } }; + + private static string GetString(Dictionary a, string k) => + a != null && a.ContainsKey(k) && a[k] != null ? a[k].ToString() : null; + + private static bool GetBool(Dictionary a, string k) + { + if (a == null || !a.ContainsKey(k) || a[k] == null) return false; + string s = a[k].ToString().ToLowerInvariant(); + return s == "true" || s == "1"; + } + + private static int GetInt(Dictionary a, string k, int def) => + a != null && a.ContainsKey(k) && a[k] != null && int.TryParse(a[k].ToString(), out var i) ? i : def; } } diff --git a/package.json b/package.json index 1e725d1..8cfe8dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.anklebreaker.unity-mcp", - "version": "2.35.0", + "version": "2.36.0", "displayName": "AnkleBreaker Unity MCP", "description": "MCP (Model Context Protocol) bridge plugin for Unity Editor. Enables AI assistants like Claude to control Unity Editor via a local HTTP API — manage scenes, GameObjects, components, assets, builds, and more.", "unity": "2021.3", From 8a23f557414d53f26d93148e0215fe3885b6db0b Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Sun, 19 Jul 2026 01:39:54 +0200 Subject: [PATCH 09/15] [OPTI] SceneHierarchy : - Dense per-node output by default: omit default-valued fields (active=true, tag=Untagged, layer=Default, origin position, universal Transform component, childCount implied by a complete children array); absent field means the default - Non-default info always preserved (verified live: inactive/tag/layer/position all survive) - verbose:true restores the old always-present shape (behavior change disclosed in CHANGELOG) - Measured 43% smaller on a representative 221-node hierarchy (42.3KB -> 24.4KB) - Bump package 2.36.0 -> 2.37.0 --- CHANGELOG.md | 5 +++++ Editor/MCPSceneCommands.cs | 40 +++++++++++++++++++++++++++----------- package.json | 2 +- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebe76ef..69ff0dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this package will be documented in this file. +## [2.37.0] - 2026-07-19 + +### Changed (context efficiency) +- **`scene/hierarchy` is dense by default** — per-node fields carrying their default value are omitted, and an absent field means the default: `active` (true), `tag` (Untagged), `layer` (Default), `position` (origin; Unity's approximate `Vector3 ==` treats float noise as origin), the universal `Transform` component entry, and `childCount` when a complete `children` array already implies it. Non-default information is always emitted. Measured **43% smaller** on a representative 221-node hierarchy (42.3KB → 24.4KB); scenes with many empty organizer objects cut more. **Behavior change:** consumers that require the old always-present per-node shape pass `verbose:true` (the server companion 2.35.0 declares the parameter). + ## [2.36.0] - 2026-07-18 ### Added (per-action / per-agent Undo) diff --git a/Editor/MCPSceneCommands.cs b/Editor/MCPSceneCommands.cs index da4f5f9..c8bce86 100755 --- a/Editor/MCPSceneCommands.cs +++ b/Editor/MCPSceneCommands.cs @@ -83,6 +83,13 @@ public static object GetHierarchy(Dictionary args) if (args != null && args.ContainsKey("maxNodes")) maxNodes = System.Convert.ToInt32(args["maxNodes"]); + // Dense by default: per-node fields that carry their default value (active=true, + // tag=Untagged, layer=Default, position=origin, the universal Transform component, + // childCount implied by a complete children array) are omitted — on real scenes + // that's most of the payload. verbose:true restores the full per-node shape. + bool verbose = args != null && args.ContainsKey("verbose") && args["verbose"] != null + && (args["verbose"].ToString().ToLowerInvariant() == "true" || args["verbose"].ToString() == "1"); + // Optional: only return hierarchy under a specific parent path string parentPath = null; if (args != null && args.ContainsKey("parentPath")) @@ -112,7 +119,7 @@ public static object GetHierarchy(Dictionary args) foreach (var root in startObjects) { - var node = BuildHierarchyNode(root, 0, maxDepth, ref nodeCount, maxNodes); + var node = BuildHierarchyNode(root, 0, maxDepth, ref nodeCount, maxNodes, verbose); if (node != null) hierarchy.Add(node); if (nodeCount >= maxNodes) @@ -158,7 +165,7 @@ private static void CountRecursive(GameObject go, ref int count) } private static Dictionary BuildHierarchyNode( - GameObject go, int depth, int maxDepth, ref int nodeCount, int maxNodes) + GameObject go, int depth, int maxDepth, ref int nodeCount, int maxNodes, bool verbose) { if (nodeCount >= maxNodes) return null; @@ -168,21 +175,30 @@ private static Dictionary BuildHierarchyNode( var components = new List(); foreach (var comp in go.GetComponents()) { - if (comp != null) - components.Add(comp.GetType().Name); + if (comp == null) continue; + string typeName = comp.GetType().Name; + // Dense mode: every GameObject has a Transform — listing it is pure noise. + // (RectTransform stays: it distinguishes UI objects.) + if (!verbose && typeName == "Transform") continue; + components.Add(typeName); } var node = new Dictionary { { "name", go.name }, { "instanceId", MCPObjectId.Get(go) }, - { "active", go.activeSelf }, - { "tag", go.tag }, - { "layer", LayerMask.LayerToName(go.layer) }, - { "components", components }, - { "position", VectorToDict(go.transform.position) }, }; + // Dense mode omits default-valued fields; their absence means the default. + // verbose:true restores the always-present shape. + if (verbose || !go.activeSelf) node["active"] = go.activeSelf; + if (verbose || !go.CompareTag("Untagged")) node["tag"] = go.tag; + if (verbose || go.layer != 0) node["layer"] = LayerMask.LayerToName(go.layer); + if (verbose || components.Count > 0) node["components"] = components; + // Vector3 == uses an approximate comparison, so float noise still counts as origin. + if (verbose || go.transform.position != Vector3.zero) + node["position"] = VectorToDict(go.transform.position); + if (depth < maxDepth && go.transform.childCount > 0) { var children = new List(); @@ -196,13 +212,15 @@ private static Dictionary BuildHierarchyNode( node["childrenTruncated"] = true; break; } - var childNode = BuildHierarchyNode(go.transform.GetChild(i).gameObject, depth + 1, maxDepth, ref nodeCount, maxNodes); + var childNode = BuildHierarchyNode(go.transform.GetChild(i).gameObject, depth + 1, maxDepth, ref nodeCount, maxNodes, verbose); if (childNode != null) children.Add(childNode); } if (children.Count > 0) node["children"] = children; - if (!node.ContainsKey("childCount")) + // A complete children array implies childCount — only emit when it adds + // information (truncated above, or verbose callers wanting the old shape). + if (!node.ContainsKey("childCount") && (verbose || children.Count != go.transform.childCount)) node["childCount"] = go.transform.childCount; } else if (go.transform.childCount > 0) diff --git a/package.json b/package.json index 8cfe8dd..80e28ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.anklebreaker.unity-mcp", - "version": "2.36.0", + "version": "2.37.0", "displayName": "AnkleBreaker Unity MCP", "description": "MCP (Model Context Protocol) bridge plugin for Unity Editor. Enables AI assistants like Claude to control Unity Editor via a local HTTP API — manage scenes, GameObjects, components, assets, builds, and more.", "unity": "2021.3", From 4fe56673d88ac772d631b26125d0fd8c995632f4 Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Mon, 20 Jul 2026 01:39:19 +0200 Subject: [PATCH 10/15] [FIX] ProBuilder, shapes created with NULL material (magenta render + CSG crash 'Value cannot be null: key') : create-shape always assigns explicit material or BuiltinMaterials.defaultMaterial; boolean pre-flights operand materials (Undo-tracked, materialsDefaulted disclosed) [FIX] ProBuilder, boolean result double-offset (CSG output is world-space but result was placed at target position, carved walls landed outside the level) : result sits at identity then pivot-centered on its geometry [FIX] Graphics, scene-capture rendered a stale camera (backgrounded editor never repaints the SceneView so code-driven LookAt/focus was ignored) : sync render camera to view pivot/rotation/size before rendering All three found by the live ProBuilder level-build test scenario (unity-mcp-server 2.35.1); level built + visually verified via the fixed capture. Bump 2.37.0 -> 2.37.1 --- CHANGELOG.md | 7 ++++ Editor/MCPGraphicsCommands.cs | 5 +++ Editor/MCPProBuilderCommands.cs | 61 ++++++++++++++++++++++++++++----- package.json | 2 +- 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ff0dc..586f546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this package will be documented in this file. +## [2.37.1] - 2026-07-19 + +### Fixed (found by the live ProBuilder level-build test scenario) +- **ProBuilder shapes were created with a NULL material** — they rendered with the magenta missing-material look and crashed boolean CSG ("Value cannot be null. Parameter name: key", CSG keys a dictionary by material). `create-shape` now always ends with a real material (the explicit `material` arg, else ProBuilder's default), and `boolean` pre-flights both operands, assigning the default to any null slot (Undo-tracked, disclosed as `materialsDefaulted: true` in the result). +- **`boolean` results were double-offset** — CSG output vertices are in world space, but the result object was also placed at the target's position, pushing the mesh a full offset away (a carved wall landed outside the level). The result now sits at the identity transform, then gets its pivot centered on the geometry so it behaves like a normal object for later transforms. +- **`graphics/scene-capture` captured a stale camera** — a backgrounded editor never repaints the SceneView, so its camera lagged behind code-driven `LookAt`/focus changes and captures showed the old view. The render camera is now synced to the view's pivot/rotation/size before rendering, so captures always reflect the requested view. + ## [2.37.0] - 2026-07-19 ### Changed (context efficiency) diff --git a/Editor/MCPGraphicsCommands.cs b/Editor/MCPGraphicsCommands.cs index 5b281f3..7a7f28d 100644 --- a/Editor/MCPGraphicsCommands.cs +++ b/Editor/MCPGraphicsCommands.cs @@ -142,6 +142,11 @@ public static object CaptureSceneView(Dictionary args) try { var camera = sceneView.camera; + // A backgrounded editor doesn't repaint the SceneView, so its camera can lag + // behind pivot/rotation/size changes made through code (LookAt, focus). Sync + // the render camera to the view state so captures reflect the requested view. + camera.transform.rotation = sceneView.rotation; + camera.transform.position = sceneView.pivot - sceneView.rotation * Vector3.forward * sceneView.cameraDistance; rt = new RenderTexture(width, height, 24); camera.targetTexture = rt; camera.Render(); diff --git a/Editor/MCPProBuilderCommands.cs b/Editor/MCPProBuilderCommands.cs index c7fea04..3fac62b 100644 --- a/Editor/MCPProBuilderCommands.cs +++ b/Editor/MCPProBuilderCommands.cs @@ -83,11 +83,13 @@ public static object CreateShape(Dictionary args) if (args.ContainsKey("position") && args["position"] is Dictionary p) go.transform.position = new Vector3(GetFloat(p, "x", 0), GetFloat(p, "y", 0), GetFloat(p, "z", 0)); - if (args.ContainsKey("material")) - { - var mat = AssetDatabase.LoadAssetAtPath(args["material"].ToString()); - if (mat != null) pb.SetMaterial(pb.faces, mat); - } + // ShapeGenerator leaves the renderer's material NULL — the shape renders magenta + // and a null material key crashes ProBuilder's CSG. Always end with a real + // material: the explicit arg when given, ProBuilder's default otherwise. + var mat = args.ContainsKey("material") + ? AssetDatabase.LoadAssetAtPath(args["material"].ToString()) + : null; + pb.SetMaterial(pb.faces, mat != null ? mat : BuiltinMaterials.defaultMaterial); Rebuild(pb); Undo.RegisterCreatedObjectUndo(go, "Create ProBuilder " + shape); @@ -241,13 +243,18 @@ public static object BooleanOp(Dictionary args) case "intersect": case "intersection": method = "Intersect"; break; default: return Error($"Unknown boolean operation '{op}'. Use union, subtract, or intersect."); } + // CSG builds a material lookup table and throws on a null key — meshes with + // missing material slots (e.g. hand-built or older shapes) get ProBuilder's + // default assigned first (Undo-tracked, disclosed in the result). + bool materialsDefaulted = EnsureRendererMaterials(a) | EnsureRendererMaterials(b); // ProBuilder's CSG class is internal, so invoke it via reflection. var mesh = InvokeCsg(method, a, b, out var csgErr); if (mesh == null) return Error(csgErr ?? "Boolean operation produced no mesh (are both objects solid meshes?)."); var result = mesh; + // CSG output vertices are in WORLD space — the result object must sit at the + // identity transform (setting it to an operand's position double-offsets the mesh). var go = new GameObject($"PB_Boolean_{op}"); - go.transform.position = a.transform.position; var mf = go.AddComponent(); mf.sharedMesh = result; var mr = go.AddComponent(); @@ -258,10 +265,17 @@ public static object BooleanOp(Dictionary args) // AddComponent nulls the MeshFilter's sharedMesh. var pb = Undo.AddComponent(go); var imported = MeshImporter_TryImport(pb, result, srcMat != null ? new[] { srcMat } : new Material[0]); - if (imported) Rebuild(pb); + if (imported) + { + Rebuild(pb); + // Move the pivot from the world origin to the geometry center so the result + // behaves like a normal object for later transforms/edits. + pb.CenterPivot(null); + Rebuild(pb); + } Undo.RegisterCreatedObjectUndo(go, "ProBuilder Boolean"); - return new Dictionary + var boolResult = new Dictionary { { "success", true }, { "operation", op }, @@ -270,6 +284,8 @@ public static object BooleanOp(Dictionary args) { "vertexCount", result.vertexCount }, { "editableProBuilder", imported }, }; + if (materialsDefaulted) boolResult["materialsDefaulted"] = true; + return boolResult; } public static object Combine(Dictionary args) @@ -361,6 +377,35 @@ private static void UndoRecord(ProBuilderMesh pb, string msg) Undo.RegisterCompleteObjectUndo(pb, msg); } + /// + /// Replace null material slots with ProBuilder's default material. CSG keys a + /// dictionary by material and throws ("Value cannot be null. Parameter name: key") + /// when any slot is null. Undo-tracked; returns whether anything changed. + /// + private static bool EnsureRendererMaterials(GameObject go) + { + var mr = go.GetComponent(); + if (mr == null) return false; + var mats = mr.sharedMaterials; + bool changed = false; + if (mats == null || mats.Length == 0) + { + mats = new[] { BuiltinMaterials.defaultMaterial }; + changed = true; + } + else + { + for (int i = 0; i < mats.Length; i++) + if (mats[i] == null) { mats[i] = BuiltinMaterials.defaultMaterial; changed = true; } + } + if (changed) + { + Undo.RegisterCompleteObjectUndo(mr, "Assign Default Material"); + mr.sharedMaterials = mats; + } + return changed; + } + /// Invoke ProBuilder's internal CSG.Union/Subtract/Intersect via reflection. private static Mesh InvokeCsg(string method, GameObject a, GameObject b, out string error) { diff --git a/package.json b/package.json index 80e28ba..dee5f3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.anklebreaker.unity-mcp", - "version": "2.37.0", + "version": "2.37.1", "displayName": "AnkleBreaker Unity MCP", "description": "MCP (Model Context Protocol) bridge plugin for Unity Editor. Enables AI assistants like Claude to control Unity Editor via a local HTTP API — manage scenes, GameObjects, components, assets, builds, and more.", "unity": "2021.3", From b1c0c23566904573f866a2ed8ee2d59439639ee6 Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Tue, 21 Jul 2026 15:12:20 +0200 Subject: [PATCH 11/15] [FEAT] News : - Mobile-style devlog notifications: MCPNewsService polls the AnkleBreaker devlog RSS (6h cadence, plain GET, cached across reloads), per-user read state (global EditorPrefs), first run seeds backlog read except the newest post (a single gentle '1') - Toolbar unseen badge: 'MCP #N' text badge on the native 6000.3+ element, real accent-orange badge pill on the pre-6000.3 fallback; tooltip lists new-post count - Toolbar dropdown gains an AnkleBreaker News section (latest 5, unseen marked, click opens + marks read, Mark All Read, Open Devlog Page) + Settings/News Notifications opt-out - Live-validated against the real feed: 6 posts parsed, unseen=1 on first run, badge clears on read, seen state persists [REFACTO] Dashboard : - Rebuilt Window > AB Unity MCP in UI Toolkit, branded to the studio palette (warm-brown + molten-orange): new MCPTheme loads the shared welcome-window brand sheet cross-assembly + MCPDashboardStyles.uss (BEM) - All IMGUI capabilities preserved (status, controls, queue + per-agent depth, project context, agent sessions, recent actions, categories + self-tests, settings, version/updates) + new News panel with category chips and unseen highlight - Sections refresh on signature change only, in isolation (one failing source cannot blank others), self-heal against Unity's layout-restore stomping (first population deferred one frame, content-aware skip guards) - Bump package 2.37.1 -> 2.38.0 --- CHANGELOG.md | 10 + Editor/MCPDashboardStyles.uss | 178 +++ Editor/MCPDashboardStyles.uss.meta | 11 + Editor/MCPDashboardWindow.cs | 1633 ++++++++++++++-------------- Editor/MCPNewsService.cs | 358 ++++++ Editor/MCPNewsService.cs.meta | 11 + Editor/MCPTheme.cs | 47 + Editor/MCPTheme.cs.meta | 11 + Editor/MCPToolbarElement.cs | 78 ++ package.json | 2 +- 10 files changed, 1550 insertions(+), 789 deletions(-) create mode 100644 Editor/MCPDashboardStyles.uss create mode 100644 Editor/MCPDashboardStyles.uss.meta create mode 100644 Editor/MCPNewsService.cs create mode 100644 Editor/MCPNewsService.cs.meta create mode 100644 Editor/MCPTheme.cs create mode 100644 Editor/MCPTheme.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 586f546..e0e6bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this package will be documented in this file. +## [2.38.0] - 2026-07-19 + +### Added (studio news notifications) +- **Mobile-style devlog notifications** — the plugin now surfaces new posts from the AnkleBreaker devlog (`anklebreaker-studio.com/devlog`): the MCP toolbar element shows an unseen badge (`MCP ●1`; a real accent-orange badge pill on the pre-6000.3 fallback toolbar), the toolbar dropdown gains an **AnkleBreaker News** section (latest 5 posts, unseen marked, click opens + marks read, Mark All Read, Open Devlog Page), and the dashboard gains a full **News panel** (all posts with category chips + dates, unseen highlighted in the brand accent). +- `MCPNewsService`: polls the devlog RSS feed at most every 6 hours (plain GET, nothing sent), caches posts across domain reloads, and tracks read state **per user** (global EditorPrefs — reading a post once clears it in every project). First run seeds the backlog as read except the newest post, so a fresh install shows a single gentle "1", not six. Fully opt-out via Settings → News Notifications (toolbar menu or dashboard). + +### Changed (dashboard reworked in UI Toolkit, studio branding) +- **The dashboard (Window → AB Unity MCP) is rebuilt in UI Toolkit** on the AnkleBreaker theme — the deep warm-brown + molten-orange palette of the studio website, shared with the welcome window's brand stylesheet (new `MCPTheme` loads it cross-assembly + `MCPDashboardStyles.uss`). Every capability of the old IMGUI window is preserved: connection status, server controls, request queue with per-agent depth, project context management, active agent sessions, recent actions, feature categories with self-test status/details/toggles, settings (auto-start, manual port, MPPM, reset), version + update check. +- Dynamic sections refresh on a 750 ms schedule but only rebuild when a content signature changes; each section refreshes in isolation (one failing data source can't blank the others) and self-heals if the layout-restore path stomps its content (first population is deferred one frame past Unity's view-data restore). + ## [2.37.1] - 2026-07-19 ### Fixed (found by the live ProBuilder level-build test scenario) diff --git a/Editor/MCPDashboardStyles.uss b/Editor/MCPDashboardStyles.uss new file mode 100644 index 0000000..3d1c202 --- /dev/null +++ b/Editor/MCPDashboardStyles.uss @@ -0,0 +1,178 @@ +/* AnkleBreaker MCP Dashboard styles — layered on top of the shared brand sheet + * (UnityMcpWelcomeTheme.uss: warm-brown bg, molten-orange accent #d97a2a/#f4a047). + * BEM: block .ab-dash, elements __x, modifiers --y. */ + +.hidden { + display: none; +} + +.ab-dash__scroll { + flex-grow: 1; + padding: 10px 12px; +} + +/* ---- Status dots ---------------------------------------------------------- */ + +.ab-dash__dot { + width: 9px; + height: 9px; + border-radius: 5px; + margin-right: 7px; + flex-shrink: 0; + background-color: rgb(128, 128, 128); +} + +.ab-dash__dot--green { background-color: rgb(74, 201, 93); } +.ab-dash__dot--red { background-color: rgb(226, 74, 62); } +.ab-dash__dot--yellow { background-color: rgb(230, 204, 43); } +.ab-dash__dot--grey { background-color: rgb(128, 128, 128); } +.ab-dash__dot--blue { background-color: rgb(102, 179, 255); } +.ab-dash__dot--accent { background-color: rgb(244, 160, 71); } + +/* ---- Rows / text ---------------------------------------------------------- */ + +.ab-dash__row { + flex-direction: row; + align-items: center; + margin-bottom: 3px; +} + +.ab-dash__grow { + flex-grow: 1; +} + +.ab-dash__label { + color: rgb(232, 224, 214); +} + +.ab-dash__bold { + -unity-font-style: bold; + color: rgb(238, 230, 222); +} + +.ab-dash__mini { + font-size: 10px; + color: rgba(232, 224, 214, 0.62); +} + +.ab-dash__accent-text { + color: rgb(244, 160, 71); +} + +.ab-dash__blue-text { + color: rgb(102, 179, 255); +} + +.ab-dash__green-text { color: rgb(74, 201, 93); } +.ab-dash__red-text { color: rgb(226, 74, 62); } +.ab-dash__yellow-text { color: rgb(230, 204, 43); } + +/* ---- Foldout sections ----------------------------------------------------- */ + +.ab-window .unity-foldout__text { + -unity-font-style: bold; + font-size: 12px; + color: rgb(238, 230, 222); +} + +.ab-window .unity-foldout__toggle { + margin-bottom: 2px; +} + +.ab-dash__section { + margin-bottom: 10px; +} + +/* ---- Badges / pills ------------------------------------------------------- */ + +.ab-badge { + -unity-text-align: middle-center; + -unity-font-style: bold; + font-size: 9px; + color: rgb(31, 23, 15); + background-color: rgb(244, 160, 71); + border-radius: 7px; + padding: 1px 5px; + margin-left: 6px; +} + +.ab-chip { + font-size: 9px; + color: rgba(244, 160, 71, 0.9); + background-color: rgba(217, 122, 42, 0.14); + border-width: 1px; + border-color: rgba(217, 122, 42, 0.35); + border-radius: 3px; + padding: 0 4px; + margin-left: 6px; +} + +/* ---- News ----------------------------------------------------------------- */ + +.ab-news__item { + flex-direction: row; + align-items: center; + padding: 5px 6px; + border-radius: 4px; + margin-bottom: 2px; + border-left-width: 2px; + border-left-color: rgba(0, 0, 0, 0); +} + +.ab-news__item:hover { + background-color: rgba(217, 122, 42, 0.14); +} + +.ab-news__item--unseen { + border-left-color: rgb(244, 160, 71); + background-color: rgba(217, 122, 42, 0.08); +} + +.ab-news__title { + color: rgb(235, 227, 217); + flex-shrink: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Truncate-with-ellipsis for any row label that must never push its neighbors. */ +.ab-dash__ellipsis { + flex-shrink: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.ab-news__item--unseen .ab-news__title { + -unity-font-style: bold; + color: rgb(244, 160, 71); +} + +/* ---- Progress ------------------------------------------------------------- */ + +.ab-dash__progress-track { + height: 6px; + border-radius: 3px; + background-color: rgba(217, 122, 42, 0.15); + flex-grow: 1; + margin-left: 8px; + overflow: hidden; +} + +.ab-dash__progress-fill { + height: 6px; + background-color: rgb(217, 122, 42); +} + +/* ---- Details boxes -------------------------------------------------------- */ + +.ab-dash__details { + background-color: rgba(0, 0, 0, 0.25); + border-radius: 4px; + padding: 6px 8px; + margin: 2px 0 6px 22px; + white-space: normal; + font-size: 10px; + color: rgba(232, 224, 214, 0.8); +} diff --git a/Editor/MCPDashboardStyles.uss.meta b/Editor/MCPDashboardStyles.uss.meta new file mode 100644 index 0000000..a748b01 --- /dev/null +++ b/Editor/MCPDashboardStyles.uss.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e5a7c3f14b84d2a86e0f9d1c7b35503 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 diff --git a/Editor/MCPDashboardWindow.cs b/Editor/MCPDashboardWindow.cs index 018c881..adcfbbe 100644 --- a/Editor/MCPDashboardWindow.cs +++ b/Editor/MCPDashboardWindow.cs @@ -1,788 +1,845 @@ -using System.Collections.Generic; -using UnityEditor; -using UnityEngine; - -namespace UnityMCP.Editor -{ - /// - /// Editor window providing an overview of AB Unity MCP status, feature categories, - /// server controls, queue monitoring, settings, and active agent sessions. - /// Accessible via Window > AB Unity MCP. - /// - public class MCPDashboardWindow : EditorWindow - { - private Vector2 _scrollPosition; - private bool _settingsFoldout = false; - private bool _agentsFoldout = true; - private bool _categoriesFoldout = true; - private bool _queueFoldout = true; - private bool _contextFoldout = true; - private bool _recentActionsFoldout = true; - private string _expandedTestCategory = null; - - private static readonly Color ColorGreen = new Color(0.2f, 0.8f, 0.2f); - private static readonly Color ColorRed = new Color(0.9f, 0.2f, 0.2f); - private static readonly Color ColorYellow = new Color(0.9f, 0.8f, 0.1f); - private static readonly Color ColorGrey = new Color(0.5f, 0.5f, 0.5f); - private static readonly Color ColorBlue = new Color(0.4f, 0.7f, 1.0f); - - private GUIStyle _headerStyle; - private GUIStyle _subHeaderStyle; - private GUIStyle _dotStyle; - private bool _stylesInitialized; - - [MenuItem("Window/AB Unity MCP")] - public static void ShowWindow() - { - var window = GetWindow("AB Unity MCP"); - window.minSize = new Vector2(340, 500); - } - - private void InitStyles() - { - if (_stylesInitialized) return; - - _headerStyle = new GUIStyle(EditorStyles.largeLabel) - { - fontSize = 16, - fontStyle = FontStyle.Bold, - }; - - _subHeaderStyle = new GUIStyle(EditorStyles.boldLabel) - { - fontSize = 12, - }; - - _dotStyle = new GUIStyle(EditorStyles.label) - { - fontSize = 18, - alignment = TextAnchor.MiddleCenter, - fixedWidth = 22, - }; - - _stylesInitialized = true; - } - - private void OnInspectorUpdate() - { - // Repaint periodically for live status - Repaint(); - } - - private void OnGUI() - { - InitStyles(); - _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); - - DrawHeader(); - EditorGUILayout.Space(6); - DrawConnectionStatus(); - EditorGUILayout.Space(4); - DrawServerControls(); - EditorGUILayout.Space(8); - DrawQueueStatus(); - EditorGUILayout.Space(8); - DrawProjectContext(); - EditorGUILayout.Space(8); - DrawAgentSessions(); - EditorGUILayout.Space(8); - DrawRecentActions(); - EditorGUILayout.Space(8); - DrawCategoryStatus(); - EditorGUILayout.Space(8); - DrawSettings(); - EditorGUILayout.Space(8); - DrawVersionInfo(); - - EditorGUILayout.EndScrollView(); - } - - // ─── Header ─── - - private void DrawHeader() - { - EditorGUILayout.BeginHorizontal(); - GUILayout.FlexibleSpace(); - EditorGUILayout.LabelField("AnkleBreaker Unity MCP", _headerStyle, GUILayout.Height(28)); - GUILayout.FlexibleSpace(); - EditorGUILayout.EndHorizontal(); - } - - // ─── Connection Status ─── - - private void DrawConnectionStatus() - { - bool running = MCPBridgeServer.IsRunning; - - EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); - - // Status dot - var prevColor = GUI.color; - GUI.color = running ? ColorGreen : ColorRed; - GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22)); - GUI.color = prevColor; - - EditorGUILayout.LabelField( - running ? "Server Running" : "Server Stopped", - EditorStyles.boldLabel); - - GUILayout.FlexibleSpace(); - - // Show actual active port when running, settings port when stopped - int displayPort = running ? MCPBridgeServer.ActivePort : MCPSettingsManager.Port; - string portLabel = running && !MCPSettingsManager.UseManualPort - ? $"Port {displayPort} (auto)" - : $"Port {displayPort}"; - EditorGUILayout.LabelField(portLabel, GUILayout.Width(100)); - - // Cache values once per event to prevent Layout/Repaint mismatch. - // Using local bools ensures the same controls exist in both passes. - int agents = MCPRequestQueue.ActiveSessionCount; - int queued = MCPRequestQueue.TotalQueuedCount; - bool showAgents = agents > 0; - bool showQueued = queued > 0; - - // Always draw the same number of controls regardless of state — - // hide them with alpha when inactive to avoid IMGUI control count mismatch. - var savedAlpha = GUI.color.a; - - // Agent count indicator - GUI.color = showAgents ? ColorGreen : new Color(0, 0, 0, 0); - GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22)); - GUI.color = showAgents ? new Color(prevColor.r, prevColor.g, prevColor.b, savedAlpha) : new Color(0, 0, 0, 0); - EditorGUILayout.LabelField(showAgents ? $"{agents} agent{(agents > 1 ? "s" : "")}" : "", GUILayout.Width(65)); - - // Queue count indicator - GUI.color = showQueued ? ColorYellow : new Color(0, 0, 0, 0); - GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22)); - GUI.color = showQueued ? new Color(prevColor.r, prevColor.g, prevColor.b, savedAlpha) : new Color(0, 0, 0, 0); - EditorGUILayout.LabelField(showQueued ? $"{queued} queued" : "", GUILayout.Width(65)); - - GUI.color = prevColor; - - EditorGUILayout.EndHorizontal(); - - // ParrelSync clone indicator (shown below the main status bar) - if (MCPInstanceRegistry.IsParrelSyncClone()) - { - EditorGUILayout.BeginHorizontal(); - GUILayout.Space(24); - var cloneStyle = new GUIStyle(EditorStyles.miniLabel) - { - normal = { textColor = ColorBlue }, - fontStyle = FontStyle.Italic, - }; - int cloneIdx = MCPInstanceRegistry.GetParrelSyncCloneIndex(); - EditorGUILayout.LabelField( - $"\u2937 ParrelSync Clone #{cloneIdx}", - cloneStyle); - EditorGUILayout.EndHorizontal(); - } - } - - // ─── Server Controls ─── - - private void DrawServerControls() - { - EditorGUILayout.BeginHorizontal(); - - bool running = MCPBridgeServer.IsRunning; - - GUI.enabled = !running; - if (GUILayout.Button("Start", GUILayout.Height(24))) - MCPBridgeServer.Start(); - - GUI.enabled = running; - if (GUILayout.Button("Stop", GUILayout.Height(24))) - MCPBridgeServer.Stop(); - - GUI.enabled = true; - if (GUILayout.Button("Restart", GUILayout.Height(24))) - { - MCPBridgeServer.Stop(); - EditorApplication.delayCall += () => MCPBridgeServer.Start(); - } - - EditorGUILayout.EndHorizontal(); - } - - // ─── Queue Status (Multi-Agent) ─── - - private void DrawQueueStatus() - { - _queueFoldout = EditorGUILayout.Foldout(_queueFoldout, "Request Queue", true, EditorStyles.foldoutHeader); - if (!_queueFoldout) return; - - var queueInfo = MCPRequestQueue.GetQueueInfo(); - - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - // Summary row - EditorGUILayout.BeginHorizontal(); - - int totalQueued = 0; - if (queueInfo.ContainsKey("totalQueued")) - int.TryParse(queueInfo["totalQueued"].ToString(), out totalQueued); - - int activeAgents = 0; - if (queueInfo.ContainsKey("activeAgents")) - int.TryParse(queueInfo["activeAgents"].ToString(), out activeAgents); - - int cacheSize = 0; - if (queueInfo.ContainsKey("completedCacheSize")) - int.TryParse(queueInfo["completedCacheSize"].ToString(), out cacheSize); - - var prevColor = GUI.color; - GUI.color = totalQueued > 0 ? ColorYellow : ColorGreen; - GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22)); - GUI.color = prevColor; - - string statusText = totalQueued > 0 - ? $"{totalQueued} pending | {activeAgents} agents | {cacheSize} cached" - : $"Idle | {activeAgents} agents | {cacheSize} cached"; - EditorGUILayout.LabelField(statusText, EditorStyles.miniLabel); - - EditorGUILayout.EndHorizontal(); - - // Per-agent breakdown (if any queued) - if (queueInfo.ContainsKey("perAgentQueued") && queueInfo["perAgentQueued"] is Dictionary perAgent) - { - if (perAgent.Count > 0) - { - EditorGUILayout.Space(2); - EditorGUILayout.LabelField("Per-Agent Queue Depth:", EditorStyles.miniLabel); - - foreach (var kvp in perAgent) - { - EditorGUILayout.BeginHorizontal(); - GUILayout.Space(24); - - int depth = 0; - int.TryParse(kvp.Value.ToString(), out depth); - - var agentColor = depth > 0 ? ColorYellow : ColorGreen; - GUI.color = agentColor; - GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22)); - GUI.color = prevColor; - - EditorGUILayout.LabelField(kvp.Key, GUILayout.Width(160)); - EditorGUILayout.LabelField($"{depth} pending", GUILayout.Width(80)); - - EditorGUILayout.EndHorizontal(); - } - } - } - - EditorGUILayout.EndVertical(); - } - - // ─── Project Context ─── - - private void DrawProjectContext() - { - _contextFoldout = EditorGUILayout.Foldout(_contextFoldout, "Project Context", true, EditorStyles.foldoutHeader); - if (!_contextFoldout) return; - - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - // Enabled toggle - EditorGUILayout.BeginHorizontal(); - bool enabled = EditorGUILayout.Toggle("Enable Context", MCPSettingsManager.ContextEnabled); - if (enabled != MCPSettingsManager.ContextEnabled) - MCPSettingsManager.ContextEnabled = enabled; - GUILayout.FlexibleSpace(); - - // Buttons - if (GUILayout.Button("Create Templates", GUILayout.Width(110), GUILayout.Height(18))) - { - int created = MCPContextManager.CreateDefaultTemplates(); - if (created > 0) - EditorUtility.DisplayDialog("Templates Created", - $"Created {created} template file(s) in:\n{MCPSettingsManager.ContextPath}", "OK"); - else - EditorUtility.DisplayDialog("Templates Exist", - "All template files already exist.", "OK"); - } - - if (GUILayout.Button("Open Folder", GUILayout.Width(90), GUILayout.Height(18))) - { - string folderPath = MCPContextManager.GetContextFolderPath(); - if (System.IO.Directory.Exists(folderPath)) - EditorUtility.RevealInFinder(folderPath); - else - EditorUtility.DisplayDialog("Folder Not Found", - $"Context folder does not exist yet.\nClick 'Create Templates' to set it up.\n\n{folderPath}", "OK"); - } - - EditorGUILayout.EndHorizontal(); - - if (!enabled) - { - EditorGUILayout.HelpBox("Project context is disabled. Agents will not receive project documentation.", MessageType.Info); - EditorGUILayout.EndVertical(); - return; - } - - // Path display - EditorGUILayout.LabelField("Path:", MCPSettingsManager.ContextPath, EditorStyles.miniLabel); - - // File list - var files = MCPContextManager.GetContextFileList(); - bool anyFiles = false; - - foreach (var file in files) - { - if (!file.IsStandard && !file.Exists) continue; // Don't show missing custom files - - anyFiles = true; - EditorGUILayout.BeginHorizontal(); - - var prevColor = GUI.color; - if (file.Exists && file.SizeBytes > 0) - GUI.color = ColorGreen; - else if (file.Exists) - GUI.color = ColorYellow; - else - GUI.color = ColorGrey; - - GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22)); - GUI.color = prevColor; - - string displayName = file.Category; - EditorGUILayout.LabelField(displayName, GUILayout.MinWidth(140)); - - if (file.Exists) - { - string sizeLabel = file.SizeBytes > 1024 - ? $"{file.SizeBytes / 1024f:0.#} KB" - : $"{file.SizeBytes} B"; - EditorGUILayout.LabelField( - file.SizeBytes == 0 ? "empty" : sizeLabel, - EditorStyles.miniLabel, GUILayout.Width(60)); - } - else - { - EditorGUILayout.LabelField("not created", EditorStyles.miniLabel, GUILayout.Width(60)); - } - - GUILayout.FlexibleSpace(); - EditorGUILayout.EndHorizontal(); - } - - if (!anyFiles) - { - EditorGUILayout.HelpBox( - "No context files found. Click 'Create Templates' to get started.", - MessageType.Info); - } - - EditorGUILayout.EndVertical(); - } - - // ─── Recent Actions ─── - - private void DrawRecentActions() - { - _recentActionsFoldout = EditorGUILayout.Foldout(_recentActionsFoldout, "Recent Actions", true, EditorStyles.foldoutHeader); - if (!_recentActionsFoldout) return; - - var recent = MCPActionHistory.GetRecent(8); - - if (recent.Count == 0) - { - EditorGUILayout.HelpBox("No actions recorded yet.", MessageType.Info); - return; - } - - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - // Show newest first - for (int i = recent.Count - 1; i >= 0; i--) - { - var r = recent[i]; - EditorGUILayout.BeginHorizontal(); - - // Status dot - var prevColor = GUI.color; - Color dotColor; - switch (r.Status) - { - case "Completed": dotColor = ColorGreen; break; - case "Failed": dotColor = ColorRed; break; - default: dotColor = ColorYellow; break; - } - GUI.color = dotColor; - GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22)); - GUI.color = prevColor; - - // Timestamp - EditorGUILayout.LabelField(r.Timestamp.ToString("HH:mm:ss"), - EditorStyles.miniLabel, GUILayout.Width(55)); - - // Agent (short) - string agent = r.AgentId ?? "?"; - if (agent.Length > 10) agent = agent.Substring(0, 8) + ".."; - prevColor = GUI.color; - GUI.color = ColorBlue; - EditorGUILayout.LabelField(agent, EditorStyles.miniLabel, GUILayout.Width(65)); - GUI.color = prevColor; - - // Action command - string cmd = MCPActionRecord.ExtractCommand(r.ActionName); - EditorGUILayout.LabelField(cmd, EditorStyles.miniLabel, GUILayout.Width(100)); - - // Target (truncated) - string target = r.TargetPath ?? ""; - if (target.Length > 25) - target = ".." + target.Substring(target.Length - 23); - EditorGUILayout.LabelField(target, EditorStyles.miniLabel); - - GUILayout.FlexibleSpace(); - EditorGUILayout.EndHorizontal(); - } - - // Open full history button - EditorGUILayout.Space(2); - EditorGUILayout.BeginHorizontal(); - GUILayout.FlexibleSpace(); - string btnLabel = MCPActionHistory.Count > 8 - ? $"Open Full History ({MCPActionHistory.Count} actions)" - : "Open Full History"; - if (GUILayout.Button(btnLabel, GUILayout.Width(200), GUILayout.Height(20))) - { - MCPActionHistoryWindow.ShowWindow(); - } - GUILayout.FlexibleSpace(); - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.EndVertical(); - } - - // ─── Feature Categories + Test Status ─── - - private void DrawCategoryStatus() - { - _categoriesFoldout = EditorGUILayout.Foldout(_categoriesFoldout, "Feature Categories", true, EditorStyles.foldoutHeader); - if (!_categoriesFoldout) return; - - // Test controls bar - EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); - - // Summary - int passed = MCPSelfTest.PassedCount; - int failed = MCPSelfTest.FailedCount; - int warnings = MCPSelfTest.WarningCount; - int total = MCPSettingsManager.GetAllCategoryNames().Length; - - if (MCPSelfTest.IsRunning) - { - EditorGUILayout.LabelField( - $"Testing: {MCPSelfTest.CurrentCategory}...", - EditorStyles.miniLabel); - var rect = GUILayoutUtility.GetRect(100, 16, GUILayout.ExpandWidth(true)); - EditorGUI.ProgressBar(rect, MCPSelfTest.Progress, $"{(int)(MCPSelfTest.Progress * 100)}%"); - } - else if (MCPSelfTest.LastRunTime > System.DateTime.MinValue) - { - string summary = ""; - if (failed > 0) - summary += $"{failed} failed "; - if (warnings > 0) - summary += $"{warnings} warn "; - summary += $"{passed}/{total} passed"; - - var richStyle = new GUIStyle(EditorStyles.miniLabel) { richText = true }; - EditorGUILayout.LabelField(summary, richStyle, GUILayout.ExpandWidth(true)); - } - else - { - EditorGUILayout.LabelField("No tests run yet", EditorStyles.miniLabel); - } - - GUILayout.FlexibleSpace(); - - GUI.enabled = !MCPSelfTest.IsRunning && MCPBridgeServer.IsRunning; - if (GUILayout.Button("Run Tests", GUILayout.Width(80), GUILayout.Height(20))) - { - MCPSelfTest.RunAllAsync(); - } - GUI.enabled = true; - - EditorGUILayout.EndHorizontal(); - - // Category rows - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - string[] categories = MCPSettingsManager.GetAllCategoryNames(); - foreach (var cat in categories) - { - bool enabled = MCPSettingsManager.IsCategoryEnabled(cat); - var testResult = MCPSelfTest.GetResult(cat); - - EditorGUILayout.BeginHorizontal(); - - // Status dot — reflects test status when available, else enabled/disabled - var prevColor = GUI.color; - Color dotColor = GetCategoryDotColor(enabled, testResult); - GUI.color = dotColor; - GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22)); - GUI.color = prevColor; - - // Pretty name - string displayName = char.ToUpper(cat[0]) + cat.Substring(1); - EditorGUILayout.LabelField(displayName, GUILayout.Width(100)); - - // Test status label — always draw both controls to avoid IMGUI control count mismatch - bool hasTested = testResult != null && testResult.Status != MCPTestResult.TestStatus.Untested; - bool hasDetails = hasTested && (testResult.Status == MCPTestResult.TestStatus.Failed || - testResult.Status == MCPTestResult.TestStatus.Warning); - - if (hasTested) - { - string statusLabel = GetTestStatusText(testResult); - var statusStyle = new GUIStyle(EditorStyles.miniLabel) - { - normal = { textColor = dotColor }, - }; - EditorGUILayout.LabelField(statusLabel, statusStyle, GUILayout.Width(90)); - } - else - { - EditorGUILayout.LabelField("\u2014", EditorStyles.miniLabel, GUILayout.Width(90)); - } - - // Always draw the details button to keep control count stable - if (hasDetails) - { - if (GUILayout.Button("?", GUILayout.Width(20), GUILayout.Height(16))) - { - _expandedTestCategory = _expandedTestCategory == cat ? null : cat; - } - } - else - { - // Invisible placeholder — same control, no visual - var transparent = GUI.color; - GUI.color = new Color(0, 0, 0, 0); - GUILayout.Button("", GUILayout.Width(20), GUILayout.Height(16)); - GUI.color = transparent; - } - - GUILayout.FlexibleSpace(); - - bool newEnabled = EditorGUILayout.Toggle(enabled, GUILayout.Width(30)); - if (newEnabled != enabled) - MCPSettingsManager.SetCategoryEnabled(cat, newEnabled); - - EditorGUILayout.EndHorizontal(); - - // Expanded error details - if (_expandedTestCategory == cat && testResult != null && - !string.IsNullOrEmpty(testResult.Details)) - { - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.SelectableLabel( - testResult.Details, - EditorStyles.wordWrappedMiniLabel, - GUILayout.MinHeight(36)); - EditorGUILayout.EndVertical(); - } - } - - EditorGUILayout.EndVertical(); - } - - private Color GetCategoryDotColor(bool enabled, MCPTestResult result) - { - if (!enabled) return ColorGrey; - if (result == null || result.Status == MCPTestResult.TestStatus.Untested) - return enabled ? ColorGreen : ColorGrey; - - switch (result.Status) - { - case MCPTestResult.TestStatus.Passed: return ColorGreen; - case MCPTestResult.TestStatus.Warning: return ColorYellow; - case MCPTestResult.TestStatus.Failed: return ColorRed; - default: return ColorGrey; - } - } - - private string GetTestStatusText(MCPTestResult result) - { - switch (result.Status) - { - case MCPTestResult.TestStatus.Passed: - return $"\u2713 {result.DurationMs:0}ms"; - case MCPTestResult.TestStatus.Warning: - return $"\u26A0 {result.Message}"; - case MCPTestResult.TestStatus.Failed: - return $"\u2717 {result.Message}"; - default: - return "\u2014"; - } - } - - // ─── Agent Sessions ─── - - private void DrawAgentSessions() - { - _agentsFoldout = EditorGUILayout.Foldout(_agentsFoldout, "Active Agent Sessions", true, EditorStyles.foldoutHeader); - if (!_agentsFoldout) return; - - var sessions = MCPRequestQueue.GetActiveSessions(); - - if (sessions.Count == 0) - { - EditorGUILayout.HelpBox("No active agent sessions.", MessageType.Info); - return; - } - - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - foreach (var session in sessions) - { - EditorGUILayout.BeginHorizontal(); - - var prevColor = GUI.color; - GUI.color = ColorGreen; - GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22)); - GUI.color = prevColor; - - string agentId = session.ContainsKey("agentId") ? session["agentId"].ToString() : "?"; - string action = session.ContainsKey("currentAction") ? session["currentAction"].ToString() : "idle"; - object totalObj = session.ContainsKey("totalActions") ? session["totalActions"] : 0; - object queuedObj = session.ContainsKey("queuedRequests") ? session["queuedRequests"] : 0; - object completedObj = session.ContainsKey("completedRequests") ? session["completedRequests"] : 0; - object avgMs = session.ContainsKey("averageResponseTimeMs") ? session["averageResponseTimeMs"] : 0; - - EditorGUILayout.LabelField(agentId, EditorStyles.boldLabel, GUILayout.Width(160)); - EditorGUILayout.LabelField(action, GUILayout.MinWidth(80)); - GUILayout.FlexibleSpace(); - - // Queue + completed stats - int queuedInt = 0; - int.TryParse(queuedObj.ToString(), out queuedInt); - - var richStyle = new GUIStyle(EditorStyles.miniLabel) { richText = true }; - string stats = $"{totalObj} total"; - if (queuedInt > 0) - stats += $" {queuedInt}q"; - stats += $" {completedObj}ok"; - stats += $" {avgMs}ms"; - - EditorGUILayout.LabelField(stats, richStyle, GUILayout.Width(170)); - - EditorGUILayout.EndHorizontal(); - } - - EditorGUILayout.EndVertical(); - } - - // ─── Settings ─── - - private void DrawSettings() - { - _settingsFoldout = EditorGUILayout.Foldout(_settingsFoldout, "Settings", true, EditorStyles.foldoutHeader); - if (!_settingsFoldout) return; - - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - - // ─── General ─── - EditorGUILayout.LabelField("General", EditorStyles.boldLabel); - - bool autoStart = EditorGUILayout.Toggle("Auto-start on Editor Load", MCPSettingsManager.AutoStart); - if (autoStart != MCPSettingsManager.AutoStart) - MCPSettingsManager.AutoStart = autoStart; - - EditorGUILayout.Space(6); - - // ─── Port ─── - EditorGUILayout.LabelField("Port", EditorStyles.boldLabel); - - bool useManual = EditorGUILayout.Toggle("Use Manual Port", MCPSettingsManager.UseManualPort); - if (useManual != MCPSettingsManager.UseManualPort) - MCPSettingsManager.UseManualPort = useManual; - - if (useManual) - { - // Manual port entry - EditorGUILayout.BeginHorizontal(); - int port = EditorGUILayout.IntField("Server Port", MCPSettingsManager.Port); - if (port != MCPSettingsManager.Port && port > 1024 && port < 65536) - { - MCPSettingsManager.Port = port; - } - EditorGUILayout.EndHorizontal(); - - if (MCPBridgeServer.IsRunning && MCPBridgeServer.ActivePort != MCPSettingsManager.Port) - EditorGUILayout.HelpBox("Restart server to apply port change.", MessageType.Info); - } - else - { - // Auto-select info - string autoInfo = MCPBridgeServer.IsRunning - ? $"Auto-selected port {MCPBridgeServer.ActivePort} (range: {MCPInstanceRegistry.PortRangeStart}-{MCPInstanceRegistry.PortRangeEnd})" - : $"Will auto-select from range {MCPInstanceRegistry.PortRangeStart}-{MCPInstanceRegistry.PortRangeEnd}"; - EditorGUILayout.HelpBox(autoInfo, MessageType.None); - } - - EditorGUILayout.Space(6); - - // ─── Multiplayer Play Mode (MPPM) ─── - EditorGUILayout.LabelField("Multiplayer Play Mode (MPPM)", EditorStyles.boldLabel); - - // Start on MPPM Virtual Players - bool startOnVP = EditorGUILayout.Toggle( - new GUIContent("Start on Virtual Players", - "When off, the MCP bridge does not auto-start on Multiplayer Play Mode " + - "virtual players — only on the main Editor. Manual start still works."), - MCPSettingsManager.StartOnVirtualPlayers); - if (startOnVP != MCPSettingsManager.StartOnVirtualPlayers) - MCPSettingsManager.StartOnVirtualPlayers = startOnVP; - - EditorGUILayout.Space(4); - - // Reset button - if (GUILayout.Button("Reset All Settings to Defaults")) - { - if (EditorUtility.DisplayDialog("Reset Settings", - "Reset all MCP settings to defaults?", "Reset", "Cancel")) - { - MCPSettingsManager.ResetToDefaults(); - } - } - - EditorGUILayout.EndVertical(); - } - - // ─── Version Info ─── - - private void DrawVersionInfo() - { - EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); - EditorGUILayout.LabelField($"Plugin Version: {MCPUpdateChecker.CurrentVersion}", GUILayout.Width(155)); - GUILayout.FlexibleSpace(); - - if (GUILayout.Button("Check for Updates", GUILayout.Width(130))) - { - MCPUpdateChecker.CheckForUpdates((hasUpdate, latestVersion) => - { - if (hasUpdate) - { - EditorUtility.DisplayDialog("Update Available", - $"A new version ({latestVersion}) is available.\n" + - "Update via Unity Package Manager.", - "OK"); - } - else - { - EditorUtility.DisplayDialog("Up to Date", - "You are running the latest version.", "OK"); - } - }); - } - - EditorGUILayout.EndHorizontal(); - } - } -} +using System.Collections.Generic; +using System.Text; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace UnityMCP.Editor +{ + /// + /// Editor window providing an overview of AB Unity MCP status, feature categories, + /// server controls, queue monitoring, studio news, settings, and active agent + /// sessions. Accessible via Window > AB Unity MCP. + /// + /// UI Toolkit, themed to the AnkleBreaker studio palette (shared brand sheet via + /// ). Dynamic sections refresh on a schedule and only rebuild + /// their rows when a cheap content signature changes. + /// + public class MCPDashboardWindow : EditorWindow + { + private const int RefreshIntervalMs = 750; + + [MenuItem("Window/AB Unity MCP")] + public static void ShowWindow() + { + var window = GetWindow("AB Unity MCP"); + window.minSize = new Vector2(360, 500); + } + + // Cached roots for dynamic sections (rebuilt when their signature changes). + private VisualElement _statusRows; + private Button _startBtn; + private Button _stopBtn; + private VisualElement _newsRows; + private VisualElement _queueRows; + private VisualElement _contextRows; + private VisualElement _agentRows; + private VisualElement _actionRows; + private VisualElement _categoryRows; + private VisualElement _testBar; + private Toggle _autoStartToggle; + private Toggle _newsToggle; + private Toggle _manualPortToggle; + private IntegerField _portField; + private VisualElement _portManualGroup; + private Label _portAutoInfo; + private Label _portRestartHint; + private Toggle _mppmToggle; + + private string _statusSig, _newsSig, _queueSig, _contextSig, _agentSig, _actionSig, _categorySig; + private string _expandedTestCategory; + + private void OnDisable() + { + MCPNewsService.Changed -= OnNewsChanged; + } + + public void CreateGUI() + { + MCPTheme.Apply(rootVisualElement); + MCPNewsService.Changed += OnNewsChanged; + + var scroll = new ScrollView(); + scroll.AddToClassList("ab-dash__scroll"); + rootVisualElement.Add(scroll); + + BuildHeader(scroll); + BuildStatus(scroll); + BuildControls(scroll); + BuildNews(scroll); + BuildFoldout(scroll, "Request Queue", true, out _queueRows); + BuildContext(scroll); + BuildFoldout(scroll, "Active Agent Sessions", true, out _agentRows); + BuildActions(scroll); + BuildCategories(scroll); + BuildSettings(scroll); + BuildVersion(scroll); + + // First population is deferred one frame: on layout-restored windows Unity applies + // saved view-data AFTER CreateGUI, which can stomp children added synchronously here. + rootVisualElement.schedule.Execute(RefreshAll); + rootVisualElement.schedule.Execute(RefreshAll).Every(RefreshIntervalMs); + } + + private void OnNewsChanged() => RefreshNews(); + + private void RefreshAll() + { + // Each section refreshes in isolation: a transient failure (e.g. a data source + // hiccup around a domain reload) must not blank the other sections, and resetting + // the failed section's signature makes it rebuild — and self-heal — on the next tick. + Guarded(RefreshStatus, () => _statusSig = null); + Guarded(RefreshControls, null); + Guarded(RefreshNews, () => _newsSig = null); + Guarded(RefreshQueue, () => _queueSig = null); + Guarded(RefreshContext, () => _contextSig = null); + Guarded(RefreshAgents, () => _agentSig = null); + Guarded(RefreshActions, () => _actionSig = null); + Guarded(RefreshCategories, () => _categorySig = null); + Guarded(RefreshSettings, null); + } + + private static void Guarded(System.Action refresh, System.Action resetSignature) + { + try { refresh(); } + catch (System.Exception) + { + try { resetSignature?.Invoke(); } catch { } + } + } + + // ─── Small builders ────────────────────────────────────────────── + + private static VisualElement Row(VisualElement parent) + { + var row = new VisualElement(); + row.AddToClassList("ab-dash__row"); + parent.Add(row); + return row; + } + + private static VisualElement Dot(VisualElement row, string colorClass) + { + var dot = new VisualElement(); + dot.AddToClassList("ab-dash__dot"); + dot.AddToClassList(colorClass); + row.Add(dot); + return dot; + } + + private static Label Text(VisualElement row, string text, string styleClass) + { + var label = new Label(text); + label.AddToClassList(styleClass); + row.Add(label); + return label; + } + + private static void Grow(VisualElement row) + { + var spacer = new VisualElement(); + spacer.AddToClassList("ab-dash__grow"); + row.Add(spacer); + } + + private Foldout BuildFoldout(VisualElement parent, string title, bool open, out VisualElement rows) + { + var foldout = new Foldout { text = title, value = open }; + foldout.AddToClassList("ab-dash__section"); + parent.Add(foldout); + + var box = new VisualElement(); + box.AddToClassList("ab-box"); + foldout.Add(box); + rows = box; + return foldout; + } + + // ─── Header ────────────────────────────────────────────────────── + + private void BuildHeader(VisualElement parent) + { + var title = new Label("AnkleBreaker Unity MCP"); + title.AddToClassList("ab-title"); + parent.Add(title); + + var subtitle = new Label("Multi-agent MCP bridge for the Unity Editor"); + subtitle.AddToClassList("ab-subtitle"); + parent.Add(subtitle); + } + + // ─── Connection status ─────────────────────────────────────────── + + private void BuildStatus(VisualElement parent) + { + _statusRows = new VisualElement(); + _statusRows.AddToClassList("ab-box"); + parent.Add(_statusRows); + } + + private void RefreshStatus() + { + bool running = MCPBridgeServer.IsRunning; + int agents = MCPRequestQueue.ActiveSessionCount; + int queued = MCPRequestQueue.TotalQueuedCount; + bool clone = MCPInstanceRegistry.IsParrelSyncClone(); + int displayPort = running ? MCPBridgeServer.ActivePort : MCPSettingsManager.Port; + + string sig = $"{running}|{displayPort}|{agents}|{queued}|{clone}"; + if (sig == _statusSig && _statusRows.childCount > 0) return; + _statusSig = sig; + + _statusRows.Clear(); + + var main = Row(_statusRows); + Dot(main, running ? "ab-dash__dot--green" : "ab-dash__dot--red"); + Text(main, running ? "Server Running" : "Server Stopped", "ab-dash__bold"); + Grow(main); + string portLabel = running && !MCPSettingsManager.UseManualPort + ? $"Port {displayPort} (auto)" : $"Port {displayPort}"; + Text(main, portLabel, "ab-dash__mini"); + + if (agents > 0 || queued > 0) + { + var counts = Row(_statusRows); + if (agents > 0) + { + Dot(counts, "ab-dash__dot--green"); + Text(counts, $"{agents} agent{(agents > 1 ? "s" : "")}", "ab-dash__label"); + } + if (queued > 0) + { + Dot(counts, "ab-dash__dot--yellow"); + Text(counts, $"{queued} queued", "ab-dash__label"); + } + } + + if (clone) + { + var cloneRow = Row(_statusRows); + Text(cloneRow, $"⤷ ParrelSync Clone #{MCPInstanceRegistry.GetParrelSyncCloneIndex()}", "ab-dash__blue-text"); + } + } + + // ─── Server controls ───────────────────────────────────────────── + + private void BuildControls(VisualElement parent) + { + var row = Row(parent); + row.AddToClassList("ab-dash__section"); + + _startBtn = new Button(OnStartClicked) { text = "Start" }; + _stopBtn = new Button(OnStopClicked) { text = "Stop" }; + var restart = new Button(OnRestartClicked) { text = "Restart" }; + foreach (var b in new[] { _startBtn, _stopBtn, restart }) + { + b.AddToClassList("ab-dash__grow"); + row.Add(b); + } + } + + private void OnStartClicked() => MCPBridgeServer.Start(); + private void OnStopClicked() => MCPBridgeServer.Stop(); + + private void OnRestartClicked() + { + MCPBridgeServer.Stop(); + EditorApplication.delayCall += () => MCPBridgeServer.Start(); + } + + private void RefreshControls() + { + bool running = MCPBridgeServer.IsRunning; + _startBtn.SetEnabled(!running); + _stopBtn.SetEnabled(running); + } + + // ─── AnkleBreaker news ─────────────────────────────────────────── + + private void BuildNews(VisualElement parent) + { + BuildFoldout(parent, "AnkleBreaker News", true, out _newsRows); + } + + private void RefreshNews() + { + if (_newsRows == null) return; + + var posts = MCPNewsService.Posts; + var sb = new StringBuilder(); + sb.Append(MCPNewsService.Enabled).Append('|').Append(MCPNewsService.UnseenCount) + .Append('|').Append(MCPNewsService.LastError ?? ""); + foreach (var p in posts) sb.Append('|').Append(p.Slug); + string sig = sb.ToString(); + if (sig == _newsSig && _newsRows.childCount > 0) return; + _newsSig = sig; + + _newsRows.Clear(); + + if (!MCPNewsService.Enabled) + { + Text(_newsRows, "News notifications are disabled.", "ab-dash__mini"); + var enableBtn = new Button(OnEnableNewsClicked) { text = "Enable News" }; + _newsRows.Add(enableBtn); + return; + } + + var header = Row(_newsRows); + int unseen = MCPNewsService.UnseenCount; + Text(header, unseen > 0 ? $"{unseen} new post{(unseen > 1 ? "s" : "")}" : "You're all caught up", + unseen > 0 ? "ab-dash__accent-text" : "ab-dash__mini"); + Grow(header); + if (unseen > 0) + header.Add(new Button(MCPNewsService.MarkAllSeen) { text = "Mark All Read" }); + header.Add(new Button(OnOpenDevlogClicked) { text = "Devlog" }); + header.Add(new Button(MCPNewsService.ForceRefresh) { text = "↺" }); + + if (posts.Count == 0) + { + Text(_newsRows, MCPNewsService.LastError == null + ? "Fetching studio news…" + : $"Couldn't reach the devlog ({MCPNewsService.LastError})", "ab-dash__mini"); + return; + } + + foreach (var post in posts) + { + var item = new VisualElement(); + item.AddToClassList("ab-news__item"); + if (MCPNewsService.IsUnseen(post)) + item.AddToClassList("ab-news__item--unseen"); + + var title = new Label(post.Title); + title.AddToClassList("ab-news__title"); + item.Add(title); + + Grow(item); + + if (!string.IsNullOrEmpty(post.Category)) + { + var chip = new Label(post.Category); + chip.AddToClassList("ab-chip"); + item.Add(chip); + } + + if (post.PubDateUtc.Ticks > 0) + { + var date = new Label(post.PubDateUtc.ToLocalTime().ToString("d MMM yyyy")); + date.AddToClassList("ab-dash__mini"); + date.style.marginLeft = 6; + item.Add(date); + } + + var captured = post; + item.RegisterCallback(_ => MCPNewsService.OpenPost(captured)); + _newsRows.Add(item); + } + } + + private void OnEnableNewsClicked() + { + MCPNewsService.Enabled = true; + MCPNewsService.ForceRefresh(); + } + + private void OnOpenDevlogClicked() => Application.OpenURL(MCPNewsService.DevlogUrl); + + // ─── Request queue ─────────────────────────────────────────────── + + private void RefreshQueue() + { + var info = MCPRequestQueue.GetQueueInfo(); + int totalQueued = ReadInt(info, "totalQueued"); + int activeAgents = ReadInt(info, "activeAgents"); + int cacheSize = ReadInt(info, "completedCacheSize"); + + var sb = new StringBuilder(); + sb.Append(totalQueued).Append('|').Append(activeAgents).Append('|').Append(cacheSize); + var perAgent = info.TryGetValue("perAgentQueued", out var pa) ? pa as Dictionary : null; + if (perAgent != null) + foreach (var kvp in perAgent) sb.Append('|').Append(kvp.Key).Append(':').Append(kvp.Value); + string sig = sb.ToString(); + if (sig == _queueSig && _queueRows.childCount > 0) return; + _queueSig = sig; + + _queueRows.Clear(); + + var summary = Row(_queueRows); + Dot(summary, totalQueued > 0 ? "ab-dash__dot--yellow" : "ab-dash__dot--green"); + string statusText = totalQueued > 0 + ? $"{totalQueued} pending · {activeAgents} agents · {cacheSize} cached" + : $"Idle · {activeAgents} agents · {cacheSize} cached"; + Text(summary, statusText, "ab-dash__label"); + + if (perAgent != null && perAgent.Count > 0) + { + Text(_queueRows, "Per-agent queue depth:", "ab-dash__mini"); + foreach (var kvp in perAgent) + { + int depth = 0; + int.TryParse(kvp.Value.ToString(), out depth); + var row = Row(_queueRows); + Dot(row, depth > 0 ? "ab-dash__dot--yellow" : "ab-dash__dot--green"); + Text(row, kvp.Key, "ab-dash__label"); + Grow(row); + Text(row, $"{depth} pending", "ab-dash__mini"); + } + } + } + + // ─── Project context ───────────────────────────────────────────── + + private void BuildContext(VisualElement parent) + { + BuildFoldout(parent, "Project Context", true, out _contextRows); + } + + private void RefreshContext() + { + bool enabled = MCPSettingsManager.ContextEnabled; + var files = MCPContextManager.GetContextFileList(); + + var sb = new StringBuilder(); + sb.Append(enabled).Append('|').Append(MCPSettingsManager.ContextPath); + foreach (var f in files) sb.Append('|').Append(f.Category).Append(':').Append(f.Exists).Append(':').Append(f.SizeBytes); + string sig = sb.ToString(); + if (sig == _contextSig && _contextRows.childCount > 0) return; + _contextSig = sig; + + _contextRows.Clear(); + + var header = Row(_contextRows); + var toggle = new Toggle("Enable Context") { value = enabled }; + toggle.RegisterValueChangedCallback(OnContextToggled); + header.Add(toggle); + Grow(header); + header.Add(new Button(OnCreateTemplatesClicked) { text = "Create Templates" }); + header.Add(new Button(OnOpenContextFolderClicked) { text = "Open Folder" }); + + if (!enabled) + { + Text(_contextRows, "Project context is disabled. Agents will not receive project documentation.", "ab-dash__mini"); + return; + } + + Text(_contextRows, $"Path: {MCPSettingsManager.ContextPath}", "ab-dash__mini"); + + bool anyFiles = false; + foreach (var file in files) + { + if (!file.IsStandard && !file.Exists) continue; + anyFiles = true; + + var row = Row(_contextRows); + string dotClass = file.Exists && file.SizeBytes > 0 ? "ab-dash__dot--green" + : file.Exists ? "ab-dash__dot--yellow" : "ab-dash__dot--grey"; + Dot(row, dotClass); + Text(row, file.Category, "ab-dash__label"); + Grow(row); + string sizeLabel = !file.Exists ? "not created" + : file.SizeBytes == 0 ? "empty" + : file.SizeBytes > 1024 ? $"{file.SizeBytes / 1024f:0.#} KB" : $"{file.SizeBytes} B"; + Text(row, sizeLabel, "ab-dash__mini"); + } + + if (!anyFiles) + Text(_contextRows, "No context files found. Click 'Create Templates' to get started.", "ab-dash__mini"); + } + + private void OnContextToggled(ChangeEvent evt) => MCPSettingsManager.ContextEnabled = evt.newValue; + + private void OnCreateTemplatesClicked() + { + int created = MCPContextManager.CreateDefaultTemplates(); + EditorUtility.DisplayDialog( + created > 0 ? "Templates Created" : "Templates Exist", + created > 0 + ? $"Created {created} template file(s) in:\n{MCPSettingsManager.ContextPath}" + : "All template files already exist.", + "OK"); + } + + private void OnOpenContextFolderClicked() + { + string folderPath = MCPContextManager.GetContextFolderPath(); + if (System.IO.Directory.Exists(folderPath)) + EditorUtility.RevealInFinder(folderPath); + else + EditorUtility.DisplayDialog("Folder Not Found", + $"Context folder does not exist yet.\nClick 'Create Templates' to set it up.\n\n{folderPath}", "OK"); + } + + // ─── Agent sessions ────────────────────────────────────────────── + + private void RefreshAgents() + { + var sessions = MCPRequestQueue.GetActiveSessions(); + + var sb = new StringBuilder(); + foreach (var s in sessions) + foreach (var kvp in s) sb.Append(kvp.Key).Append(':').Append(kvp.Value).Append('|'); + string sig = sb.ToString(); + if (sig == _agentSig && _agentRows.childCount > 0) return; + _agentSig = sig; + + _agentRows.Clear(); + + if (sessions.Count == 0) + { + Text(_agentRows, "No active agent sessions.", "ab-dash__mini"); + return; + } + + foreach (var session in sessions) + { + var row = Row(_agentRows); + Dot(row, "ab-dash__dot--green"); + Text(row, Read(session, "agentId", "?"), "ab-dash__bold"); + var action = Text(row, Read(session, "currentAction", "idle"), "ab-dash__mini"); + action.AddToClassList("ab-dash__ellipsis"); + action.style.marginLeft = 8; + Grow(row); + string stats = $"{Read(session, "totalActions", "0")} total · " + + $"{Read(session, "queuedRequests", "0")}q · " + + $"{Read(session, "completedRequests", "0")}ok · " + + $"{Read(session, "averageResponseTimeMs", "0")}ms"; + Text(row, stats, "ab-dash__mini").AddToClassList("ab-dash__ellipsis"); + } + } + + // ─── Recent actions ────────────────────────────────────────────── + + private void BuildActions(VisualElement parent) + { + BuildFoldout(parent, "Recent Actions", true, out _actionRows); + } + + private void RefreshActions() + { + var recent = MCPActionHistory.GetRecent(8); + + var sb = new StringBuilder(); + foreach (var r in recent) sb.Append(r.Id).Append(':').Append(r.Status).Append('|'); + string sig = sb.ToString(); + if (sig == _actionSig && _actionRows.childCount > 0) return; + _actionSig = sig; + + _actionRows.Clear(); + + if (recent.Count == 0) + { + Text(_actionRows, "No actions recorded yet.", "ab-dash__mini"); + return; + } + + for (int i = recent.Count - 1; i >= 0; i--) + { + var r = recent[i]; + var row = Row(_actionRows); + string dotClass = r.Status == "Completed" ? "ab-dash__dot--green" + : r.Status == "Failed" ? "ab-dash__dot--red" : "ab-dash__dot--yellow"; + Dot(row, dotClass); + Text(row, r.Timestamp.ToString("HH:mm:ss"), "ab-dash__mini"); + + string agent = r.AgentId ?? "?"; + if (agent.Length > 10) agent = agent.Substring(0, 8) + ".."; + Text(row, agent, "ab-dash__blue-text").style.marginLeft = 6; + + Text(row, MCPActionRecord.ExtractCommand(r.ActionName), "ab-dash__label").style.marginLeft = 6; + + string target = r.TargetPath ?? ""; + if (target.Length > 28) target = ".." + target.Substring(target.Length - 26); + var targetLabel = Text(row, target, "ab-dash__mini"); + targetLabel.AddToClassList("ab-dash__ellipsis"); + targetLabel.style.marginLeft = 6; + Grow(row); + } + + var footer = Row(_actionRows); + Grow(footer); + string btnLabel = MCPActionHistory.Count > 8 + ? $"Open Full History ({MCPActionHistory.Count} actions)" : "Open Full History"; + footer.Add(new Button(MCPActionHistoryWindow.ShowWindow) { text = btnLabel }); + Grow(footer); + } + + // ─── Feature categories + tests ────────────────────────────────── + + private void BuildCategories(VisualElement parent) + { + var foldout = BuildFoldout(parent, "Feature Categories", true, out _categoryRows); + _testBar = new VisualElement(); + _testBar.AddToClassList("ab-dash__row"); + foldout.Insert(0, _testBar); + } + + private void RefreshCategories() + { + string[] categories = MCPSettingsManager.GetAllCategoryNames(); + + var sb = new StringBuilder(); + sb.Append(MCPSelfTest.IsRunning).Append('|').Append(MCPSelfTest.Progress.ToString("0.00")) + .Append('|').Append(MCPSelfTest.CurrentCategory).Append('|').Append(_expandedTestCategory) + .Append('|').Append(MCPBridgeServer.IsRunning); + foreach (var cat in categories) + { + var r = MCPSelfTest.GetResult(cat); + sb.Append('|').Append(cat).Append(':').Append(MCPSettingsManager.IsCategoryEnabled(cat)) + .Append(':').Append(r == null ? "-" : r.Status.ToString()).Append(':').Append(r?.Message); + } + string sig = sb.ToString(); + if (sig == _categorySig && _categoryRows.childCount > 0) return; + _categorySig = sig; + + RefreshTestBar(); + + _categoryRows.Clear(); + foreach (var cat in categories) + { + bool enabled = MCPSettingsManager.IsCategoryEnabled(cat); + var result = MCPSelfTest.GetResult(cat); + + var row = Row(_categoryRows); + Dot(row, CategoryDotClass(enabled, result)); + Text(row, char.ToUpper(cat[0]) + cat.Substring(1), "ab-dash__label"); + + bool hasTested = result != null && result.Status != MCPTestResult.TestStatus.Untested; + if (hasTested) + { + string statusClass = result.Status == MCPTestResult.TestStatus.Passed ? "ab-dash__green-text" + : result.Status == MCPTestResult.TestStatus.Warning ? "ab-dash__yellow-text" : "ab-dash__red-text"; + Text(row, TestStatusText(result), statusClass).style.marginLeft = 8; + + bool hasDetails = result.Status == MCPTestResult.TestStatus.Failed || + result.Status == MCPTestResult.TestStatus.Warning; + if (hasDetails && !string.IsNullOrEmpty(result.Details)) + { + string captured = cat; + var detailsBtn = new Button(() => ToggleDetails(captured)) { text = "?" }; + detailsBtn.style.marginLeft = 4; + row.Add(detailsBtn); + } + } + + Grow(row); + + var toggle = new Toggle { value = enabled }; + string catCapture = cat; + toggle.RegisterValueChangedCallback(evt => MCPSettingsManager.SetCategoryEnabled(catCapture, evt.newValue)); + row.Add(toggle); + + if (_expandedTestCategory == cat && result != null && !string.IsNullOrEmpty(result.Details)) + { + var details = new Label(result.Details); + details.AddToClassList("ab-dash__details"); + _categoryRows.Add(details); + } + } + } + + private void ToggleDetails(string category) + { + _expandedTestCategory = _expandedTestCategory == category ? null : category; + _categorySig = null; + RefreshCategories(); + } + + private void RefreshTestBar() + { + _testBar.Clear(); + + if (MCPSelfTest.IsRunning) + { + Text(_testBar, $"Testing: {MCPSelfTest.CurrentCategory}…", "ab-dash__mini"); + var track = new VisualElement(); + track.AddToClassList("ab-dash__progress-track"); + var fill = new VisualElement(); + fill.AddToClassList("ab-dash__progress-fill"); + fill.style.width = Length.Percent(Mathf.Clamp01(MCPSelfTest.Progress) * 100f); + track.Add(fill); + _testBar.Add(track); + return; + } + + if (MCPSelfTest.LastRunTime > System.DateTime.MinValue) + { + int failed = MCPSelfTest.FailedCount; + int warnings = MCPSelfTest.WarningCount; + int total = MCPSettingsManager.GetAllCategoryNames().Length; + if (failed > 0) Text(_testBar, $"{failed} failed", "ab-dash__red-text").style.marginRight = 8; + if (warnings > 0) Text(_testBar, $"{warnings} warn", "ab-dash__yellow-text").style.marginRight = 8; + Text(_testBar, $"{MCPSelfTest.PassedCount}/{total} passed", "ab-dash__green-text"); + } + else + { + Text(_testBar, "No tests run yet", "ab-dash__mini"); + } + + Grow(_testBar); + var runBtn = new Button(MCPSelfTest.RunAllAsync) { text = "Run Tests" }; + runBtn.SetEnabled(!MCPSelfTest.IsRunning && MCPBridgeServer.IsRunning); + _testBar.Add(runBtn); + } + + private static string CategoryDotClass(bool enabled, MCPTestResult result) + { + if (!enabled) return "ab-dash__dot--grey"; + if (result == null || result.Status == MCPTestResult.TestStatus.Untested) return "ab-dash__dot--green"; + switch (result.Status) + { + case MCPTestResult.TestStatus.Passed: return "ab-dash__dot--green"; + case MCPTestResult.TestStatus.Warning: return "ab-dash__dot--yellow"; + case MCPTestResult.TestStatus.Failed: return "ab-dash__dot--red"; + default: return "ab-dash__dot--grey"; + } + } + + private static string TestStatusText(MCPTestResult result) + { + switch (result.Status) + { + case MCPTestResult.TestStatus.Passed: return $"✓ {result.DurationMs:0}ms"; + case MCPTestResult.TestStatus.Warning: return $"⚠ {result.Message}"; + case MCPTestResult.TestStatus.Failed: return $"✗ {result.Message}"; + default: return "—"; + } + } + + // ─── Settings ──────────────────────────────────────────────────── + + private void BuildSettings(VisualElement parent) + { + BuildFoldout(parent, "Settings", false, out var box); + + Text(box, "General", "ab-section-heading"); + + _autoStartToggle = new Toggle("Auto-start on Editor Load") { value = MCPSettingsManager.AutoStart }; + _autoStartToggle.RegisterValueChangedCallback(OnAutoStartToggled); + box.Add(_autoStartToggle); + + _newsToggle = new Toggle("News Notifications") { value = MCPNewsService.Enabled }; + _newsToggle.RegisterValueChangedCallback(OnNewsToggled); + box.Add(_newsToggle); + + Text(box, "Port", "ab-section-heading"); + + _manualPortToggle = new Toggle("Use Manual Port") { value = MCPSettingsManager.UseManualPort }; + _manualPortToggle.RegisterValueChangedCallback(OnManualPortToggled); + box.Add(_manualPortToggle); + + _portManualGroup = new VisualElement(); + _portField = new IntegerField("Server Port") { value = MCPSettingsManager.Port }; + _portField.RegisterValueChangedCallback(OnPortChanged); + _portManualGroup.Add(_portField); + _portRestartHint = Text(_portManualGroup, "Restart server to apply port change.", "ab-dash__accent-text"); + box.Add(_portManualGroup); + + _portAutoInfo = Text(box, "", "ab-dash__mini"); + + Text(box, "Multiplayer Play Mode (MPPM)", "ab-section-heading"); + + _mppmToggle = new Toggle("Start on Virtual Players") + { + value = MCPSettingsManager.StartOnVirtualPlayers, + tooltip = "When off, the MCP bridge does not auto-start on Multiplayer Play Mode " + + "virtual players — only on the main Editor. Manual start still works.", + }; + _mppmToggle.RegisterValueChangedCallback(OnMppmToggled); + box.Add(_mppmToggle); + + var resetBtn = new Button(OnResetClicked) { text = "Reset All Settings to Defaults" }; + resetBtn.style.marginTop = 8; + box.Add(resetBtn); + } + + private void OnAutoStartToggled(ChangeEvent evt) => MCPSettingsManager.AutoStart = evt.newValue; + private void OnNewsToggled(ChangeEvent evt) => MCPNewsService.Enabled = evt.newValue; + private void OnManualPortToggled(ChangeEvent evt) => MCPSettingsManager.UseManualPort = evt.newValue; + private void OnMppmToggled(ChangeEvent evt) => MCPSettingsManager.StartOnVirtualPlayers = evt.newValue; + + private void OnPortChanged(ChangeEvent evt) + { + if (evt.newValue > 1024 && evt.newValue < 65536) + MCPSettingsManager.Port = evt.newValue; + } + + private void OnResetClicked() + { + if (EditorUtility.DisplayDialog("Reset Settings", "Reset all MCP settings to defaults?", "Reset", "Cancel")) + { + MCPSettingsManager.ResetToDefaults(); + _statusSig = _newsSig = _queueSig = _contextSig = _agentSig = _actionSig = _categorySig = null; + } + } + + private void RefreshSettings() + { + _autoStartToggle.SetValueWithoutNotify(MCPSettingsManager.AutoStart); + _newsToggle.SetValueWithoutNotify(MCPNewsService.Enabled); + + bool manual = MCPSettingsManager.UseManualPort; + _manualPortToggle.SetValueWithoutNotify(manual); + _portManualGroup.EnableInClassList("hidden", !manual); + _portAutoInfo.EnableInClassList("hidden", manual); + + if (manual) + { + // Don't clobber the field while the user is typing in it. + bool editing = _portField.focusController != null + && _portField.focusController.focusedElement != null + && _portField.Contains(_portField.focusController.focusedElement as VisualElement); + if (!editing) + _portField.SetValueWithoutNotify(MCPSettingsManager.Port); + bool mismatch = MCPBridgeServer.IsRunning && MCPBridgeServer.ActivePort != MCPSettingsManager.Port; + _portRestartHint.EnableInClassList("hidden", !mismatch); + } + else + { + _portAutoInfo.text = MCPBridgeServer.IsRunning + ? $"Auto-selected port {MCPBridgeServer.ActivePort} (range: {MCPInstanceRegistry.PortRangeStart}-{MCPInstanceRegistry.PortRangeEnd})" + : $"Will auto-select from range {MCPInstanceRegistry.PortRangeStart}-{MCPInstanceRegistry.PortRangeEnd}"; + } + + _mppmToggle.SetValueWithoutNotify(MCPSettingsManager.StartOnVirtualPlayers); + } + + // ─── Version footer ────────────────────────────────────────────── + + private void BuildVersion(VisualElement parent) + { + var box = new VisualElement(); + box.AddToClassList("ab-box"); + var row = Row(box); + Text(row, $"Plugin Version: {MCPUpdateChecker.CurrentVersion}", "ab-dash__mini"); + Grow(row); + row.Add(new Button(OnCheckUpdatesClicked) { text = "Check for Updates" }); + parent.Add(box); + } + + private void OnCheckUpdatesClicked() + { + MCPUpdateChecker.CheckForUpdates((hasUpdate, latestVersion) => + { + EditorUtility.DisplayDialog( + hasUpdate ? "Update Available" : "Up to Date", + hasUpdate + ? $"A new version ({latestVersion}) is available.\nUpdate via Unity Package Manager." + : "You are running the latest version.", + "OK"); + }); + } + + // ─── Helpers ───────────────────────────────────────────────────── + + private static string Read(Dictionary dict, string key, string fallback) => + dict.TryGetValue(key, out var v) && v != null ? v.ToString() : fallback; + + private static int ReadInt(Dictionary dict, string key) + { + int value = 0; + if (dict.TryGetValue(key, out var v) && v != null) + int.TryParse(v.ToString(), out value); + return value; + } + } +} diff --git a/Editor/MCPNewsService.cs b/Editor/MCPNewsService.cs new file mode 100644 index 0000000..5b0ace9 --- /dev/null +++ b/Editor/MCPNewsService.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using UnityEditor; +using UnityEngine; +using UnityEngine.Networking; + +namespace UnityMCP.Editor +{ + /// + /// Studio news notifications: polls the AnkleBreaker devlog RSS feed and tracks + /// which posts this user has seen, so the toolbar can show a mobile-style unseen + /// badge and the dashboard a news panel. + /// + /// Privacy: a plain GET of the public feed, at most every few hours, nothing sent. + /// All state is user-scoped (global EditorPrefs, NOT per-project) — reading a post + /// once marks it read everywhere. Disable entirely with . + /// First run seeds every existing post as seen EXCEPT the newest, so a fresh + /// install shows a gentle "1" instead of the whole backlog. + /// + [InitializeOnLoad] + public static class MCPNewsService + { + public const string FeedUrl = "https://anklebreaker-studio.com/devlog/feed.xml"; + public const string DevlogUrl = "https://anklebreaker-studio.com/devlog"; + + private const string KeyEnabled = "UnityMCP_news_Enabled"; + private const string KeySeenSlugs = "UnityMCP_news_SeenSlugs"; + private const string KeyNextCheckTicks = "UnityMCP_news_NextCheckTicks"; + private const string KeyCachedPosts = "UnityMCP_news_CachedPosts"; + + private const double CheckIntervalHours = 6.0; + private const int MaxSeenSlugs = 100; + + public sealed class Post + { + public string Slug; + public string Title; + public string Url; + public string Category; + public DateTime PubDateUtc; + } + + /// Fired on the main thread whenever posts or unseen state change. + public static event Action Changed; + + private static readonly List _posts = new List(); + private static HashSet _seen; + private static bool _inFlight; + private static double _nextTickCheck; + + public static IReadOnlyList Posts => _posts; + public static DateTime LastFetchUtc { get; private set; } + public static string LastError { get; private set; } + + public static bool Enabled + { + get => EditorPrefs.GetBool(KeyEnabled, true); + set + { + if (value == Enabled) return; + EditorPrefs.SetBool(KeyEnabled, value); + RaiseChanged(); + } + } + + public static int UnseenCount + { + get + { + if (!Enabled) return 0; + int count = 0; + for (int i = 0; i < _posts.Count; i++) + if (!Seen.Contains(_posts[i].Slug)) + count++; + return count; + } + } + + static MCPNewsService() + { + LoadCache(); + EditorApplication.update += Tick; + } + + public static bool IsUnseen(Post post) => + post != null && Enabled && !Seen.Contains(post.Slug); + + public static void MarkSeen(Post post) + { + if (post == null || Seen.Contains(post.Slug)) return; + Seen.Add(post.Slug); + SaveSeen(); + RaiseChanged(); + } + + public static void MarkAllSeen() + { + bool changed = false; + foreach (var p in _posts) + changed |= Seen.Add(p.Slug); + if (!changed) return; + SaveSeen(); + RaiseChanged(); + } + + /// Open a post in the browser and mark it read. + public static void OpenPost(Post post) + { + if (post == null || string.IsNullOrEmpty(post.Url)) return; + Application.OpenURL(post.Url); + MarkSeen(post); + } + + /// Fetch the feed now, regardless of the schedule. + public static void ForceRefresh() => Fetch(); + + // ─── Scheduling ─── + + private static void Tick() + { + // The real work is hours apart — only look at the clock once a minute. + if (EditorApplication.timeSinceStartup < _nextTickCheck) return; + _nextTickCheck = EditorApplication.timeSinceStartup + 60.0; + + if (!Enabled || _inFlight) return; + long due = ReadTicks(KeyNextCheckTicks); + if (DateTime.UtcNow.Ticks < due) return; + Fetch(); + } + + private static void Fetch() + { + if (_inFlight) return; + _inFlight = true; + + UnityWebRequest req = UnityWebRequest.Get(FeedUrl); + req.timeout = 15; + req.SendWebRequest().completed += _ => OnFeedDone(req); + } + + private static void OnFeedDone(UnityWebRequest req) + { + string xml = req.result == UnityWebRequest.Result.Success ? req.downloadHandler.text : null; + string error = req.result == UnityWebRequest.Result.Success ? null : req.error; + req.Dispose(); + _inFlight = false; + + // Success or failure, don't hammer the site — next attempt one interval away. + WriteTicks(KeyNextCheckTicks, DateTime.UtcNow.AddHours(CheckIntervalHours).Ticks); + + if (string.IsNullOrEmpty(xml)) + { + LastError = error ?? "Empty feed response"; + return; + } + + List parsed = ParseFeed(xml); + if (parsed.Count == 0) + { + LastError = "Feed contained no posts"; + return; + } + + LastError = null; + LastFetchUtc = DateTime.UtcNow; + + bool firstRun = !EditorPrefs.HasKey(KeySeenSlugs); + + _posts.Clear(); + _posts.AddRange(parsed); + _posts.Sort(NewestFirst); + + if (firstRun) + { + // Everything that already exists is old news to a fresh install — + // except the newest post, which greets the user with a single badge. + for (int i = 1; i < _posts.Count; i++) + Seen.Add(_posts[i].Slug); + SaveSeen(); + } + + SaveCache(); + RaiseChanged(); + } + + private static int NewestFirst(Post a, Post b) => b.PubDateUtc.CompareTo(a.PubDateUtc); + + // ─── Feed parsing (same tolerant string scanning the welcome window uses) ─── + + private static List ParseFeed(string xml) + { + var posts = new List(); + int cursor = 0; + while (true) + { + int start = xml.IndexOf("", cursor, StringComparison.Ordinal); + if (start < 0) break; + int end = xml.IndexOf("", start, StringComparison.Ordinal); + if (end < 0) break; + string item = xml.Substring(start + 6, end - start - 6); + cursor = end + 7; + + string link = StripCData(Between(item, "", "")); + string title = Decode(StripCData(Between(item, "", ""))); + if (string.IsNullOrEmpty(link) || string.IsNullOrEmpty(title)) continue; + link = link.Trim(); + + var post = new Post + { + Title = title, + Url = link, + Slug = SlugOf(link), + Category = Decode(StripCData(Between(item, "", ""))) ?? "", + }; + + string pub = StripCData(Between(item, "", "")); + DateTime when; + post.PubDateUtc = !string.IsNullOrEmpty(pub) && DateTime.TryParse( + pub, CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out when) + ? when + : DateTime.MinValue; + + posts.Add(post); + } + return posts; + } + + private static string SlugOf(string url) + { + string trimmed = url.TrimEnd('/'); + int slash = trimmed.LastIndexOf('/'); + return slash >= 0 ? trimmed.Substring(slash + 1) : trimmed; + } + + private static string Between(string source, string startTag, string endTag) + { + if (string.IsNullOrEmpty(source)) return null; + int start = source.IndexOf(startTag, StringComparison.Ordinal); + if (start < 0) return null; + start += startTag.Length; + int end = source.IndexOf(endTag, start, StringComparison.Ordinal); + return end < 0 ? null : source.Substring(start, end - start); + } + + private static string StripCData(string value) + { + if (string.IsNullOrEmpty(value)) return value; + value = value.Trim(); + const string open = ""; + if (value.StartsWith(open, StringComparison.Ordinal) && value.EndsWith(close, StringComparison.Ordinal)) + return value.Substring(open.Length, value.Length - open.Length - close.Length).Trim(); + return value; + } + + private static string Decode(string value) + { + if (string.IsNullOrEmpty(value)) return value; + return value.Replace("'", "'").Replace("'", "'").Replace(""", "\"") + .Replace("<", "<").Replace(">", ">").Replace("&", "&").Trim(); + } + + // ─── Persistence ─── + + private static HashSet Seen + { + get + { + if (_seen != null) return _seen; + _seen = new HashSet(StringComparer.Ordinal); + string raw = EditorPrefs.GetString(KeySeenSlugs, ""); + foreach (var slug in raw.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) + _seen.Add(slug); + return _seen; + } + } + + private static void SaveSeen() + { + // Keep the set bounded: current feed slugs always survive the cap. + var ordered = new List(); + foreach (var p in _posts) + if (Seen.Contains(p.Slug)) + ordered.Add(p.Slug); + foreach (var slug in Seen) + if (!ordered.Contains(slug) && ordered.Count < MaxSeenSlugs) + ordered.Add(slug); + EditorPrefs.SetString(KeySeenSlugs, string.Join(";", ordered)); + } + + private static void SaveCache() + { + var list = new List(); + foreach (var p in _posts) + { + list.Add(new Dictionary + { + { "slug", p.Slug }, + { "title", p.Title }, + { "url", p.Url }, + { "category", p.Category }, + { "ticks", p.PubDateUtc.Ticks.ToString(CultureInfo.InvariantCulture) }, + }); + } + EditorPrefs.SetString(KeyCachedPosts, MiniJson.Serialize(list)); + } + + private static void LoadCache() + { + string json = EditorPrefs.GetString(KeyCachedPosts, ""); + if (string.IsNullOrEmpty(json)) return; + try + { + if (!(MiniJson.Deserialize(json) is List list)) return; + _posts.Clear(); + foreach (var o in list) + { + if (!(o is Dictionary d)) continue; + var post = new Post + { + Slug = d.TryGetValue("slug", out var s) ? s as string : null, + Title = d.TryGetValue("title", out var t) ? t as string : null, + Url = d.TryGetValue("url", out var u) ? u as string : null, + Category = d.TryGetValue("category", out var c) ? c as string ?? "" : "", + }; + long ticks; + post.PubDateUtc = d.TryGetValue("ticks", out var k) && long.TryParse(k as string, out ticks) + ? new DateTime(ticks, DateTimeKind.Utc) + : DateTime.MinValue; + if (!string.IsNullOrEmpty(post.Slug) && !string.IsNullOrEmpty(post.Title)) + _posts.Add(post); + } + _posts.Sort(NewestFirst); + } + catch + { + _posts.Clear(); + } + } + + private static long ReadTicks(string key) + { + long ticks; + return long.TryParse(EditorPrefs.GetString(key, "0"), out ticks) ? ticks : 0L; + } + + private static void WriteTicks(string key, long value) => + EditorPrefs.SetString(key, value.ToString(CultureInfo.InvariantCulture)); + + private static void RaiseChanged() + { + try { Changed?.Invoke(); } + catch (Exception ex) { Debug.LogWarning($"[AB-UMCP] News listener threw: {ex.Message}"); } + } + } +} diff --git a/Editor/MCPNewsService.cs.meta b/Editor/MCPNewsService.cs.meta new file mode 100644 index 0000000..a48e82a --- /dev/null +++ b/Editor/MCPNewsService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c4e9a1f28d34b6a90f1c5e8b2a7d301 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MCPTheme.cs b/Editor/MCPTheme.cs new file mode 100644 index 0000000..6e5162d --- /dev/null +++ b/Editor/MCPTheme.cs @@ -0,0 +1,47 @@ +using UnityEditor; +using UnityEngine.UIElements; + +namespace UnityMCP.Editor +{ + /// + /// Applies the AnkleBreaker editor theme (deep warm-brown + molten-orange, the + /// studio website palette) to a window root. Loads the shared brand sheet the + /// welcome window ships (resolved by asset name, so it works across assemblies) + /// plus the dashboard-specific styles. Call once from CreateGUI; idempotent. + /// + internal static class MCPTheme + { + public const string ClassWindow = "ab-window"; + + private const string BaseSheetFilter = "UnityMcpWelcomeTheme t:StyleSheet"; + private const string DashboardSheetFilter = "MCPDashboardStyles t:StyleSheet"; + + private static StyleSheet _baseSheet; + private static StyleSheet _dashboardSheet; + + public static void Apply(VisualElement root) + { + if (root == null) return; + + if (!root.ClassListContains(ClassWindow)) + root.AddToClassList(ClassWindow); + + StyleSheet baseSheet = Load(ref _baseSheet, BaseSheetFilter); + if (baseSheet != null && !root.styleSheets.Contains(baseSheet)) + root.styleSheets.Add(baseSheet); + + StyleSheet dashSheet = Load(ref _dashboardSheet, DashboardSheetFilter); + if (dashSheet != null && !root.styleSheets.Contains(dashSheet)) + root.styleSheets.Add(dashSheet); + } + + private static StyleSheet Load(ref StyleSheet cache, string filter) + { + if (cache != null) return cache; + string[] guids = AssetDatabase.FindAssets(filter); + if (guids.Length > 0) + cache = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0])); + return cache; + } + } +} diff --git a/Editor/MCPTheme.cs.meta b/Editor/MCPTheme.cs.meta new file mode 100644 index 0000000..3628af0 --- /dev/null +++ b/Editor/MCPTheme.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3b8f2d6c91a54e7fb0d4a8c5e6f19202 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MCPToolbarElement.cs b/Editor/MCPToolbarElement.cs index 1148cf4..3e91683 100644 --- a/Editor/MCPToolbarElement.cs +++ b/Editor/MCPToolbarElement.cs @@ -100,6 +100,11 @@ internal static string StatusText string label = "MCP"; if (ActiveAgents > 0) label += $" [{ActiveAgents}]"; + // Mobile-style unseen-news badge (the native toolbar API is text-only; + // the pre-6000.3 fallback shows a real colored badge element instead). + int unseen = MCPNewsService.UnseenCount; + if (unseen > 0) + label += $" ●{(unseen > 9 ? "9+" : unseen.ToString())}"; return label; } } @@ -126,6 +131,9 @@ internal static string StatusTooltip tip += "\nSelf-test failures detected"; else if (HasWarnings) tip += "\nSelf-test warnings detected"; + int unseen = MCPNewsService.UnseenCount; + if (unseen > 0) + tip += $"\n{unseen} new AnkleBreaker post{(unseen > 1 ? "s" : "")}"; tip += "\nClick for options"; return tip; } @@ -143,6 +151,8 @@ private static void Initialize() private static double _nextRefreshTime; + private static int _lastUnseenNews; + private static void PeriodicRefresh() { if (EditorApplication.timeSinceStartup < _nextRefreshTime) return; @@ -162,6 +172,9 @@ private static void PeriodicRefresh() bool warnings = MCPSelfTest.HasWarnings; if (warnings != HasWarnings) { HasWarnings = warnings; changed = true; } + int unseenNews = MCPNewsService.UnseenCount; + if (unseenNews != _lastUnseenNews) { _lastUnseenNews = unseenNews; changed = true; } + if (changed) { #if UNITY_6000_3_OR_NEWER @@ -289,8 +302,40 @@ internal static void ShowMenu(Rect buttonRect) MCPSettingsManager.UseManualPort, () => MCPSettingsManager.UseManualPort = !MCPSettingsManager.UseManualPort); + menu.AddItem( + new GUIContent("Settings/News Notifications"), + MCPNewsService.Enabled, + () => MCPNewsService.Enabled = !MCPNewsService.Enabled); + menu.AddSeparator(""); + // AnkleBreaker news — unseen posts carry a dot, clicking opens + marks read. + if (MCPNewsService.Enabled) + { + int unseen = MCPNewsService.UnseenCount; + var posts = MCPNewsService.Posts; + menu.AddDisabledItem(new GUIContent(unseen > 0 + ? $"AnkleBreaker News — {unseen} new" + : "AnkleBreaker News")); + + int shown = 0; + foreach (var post in posts) + { + if (shown++ >= 5) break; + string marker = MCPNewsService.IsUnseen(post) ? "● " : " "; + var captured = post; + menu.AddItem(new GUIContent($"{marker}{captured.Title}"), false, + () => MCPNewsService.OpenPost(captured)); + } + + if (unseen > 0) + menu.AddItem(new GUIContent("Mark All Read"), false, MCPNewsService.MarkAllSeen); + menu.AddItem(new GUIContent("Open Devlog Page"), false, + () => Application.OpenURL(MCPNewsService.DevlogUrl)); + + menu.AddSeparator(""); + } + // Dashboard & Updates menu.AddItem(new GUIContent("Open Dashboard..."), false, () => MCPDashboardWindow.ShowWindow()); menu.AddItem(new GUIContent("Check for Updates..."), false, () => @@ -349,6 +394,7 @@ internal static class MCPToolbarFallback private static VisualElement _statusDot; private static Label _statusLabel; private static Label _agentBadge; + private static Label _newsBadge; private static int _retryCount; private const int MaxRetries = 50; @@ -356,6 +402,8 @@ internal static class MCPToolbarFallback private static readonly Color kStopped = new Color(0.90f, 0.25f, 0.25f); private static readonly Color kWarning = new Color(0.90f, 0.80f, 0.10f); private static readonly Color kBadgeBg = new Color(0.40f, 0.75f, 1.00f); + // AnkleBreaker brand accent (#f4a047) — the mobile-style unseen-news badge. + private static readonly Color kNewsBadgeBg = new Color(0.957f, 0.627f, 0.278f); static MCPToolbarFallback() { @@ -505,6 +553,25 @@ private static VisualElement BuildElement() _agentBadge.style.display = DisplayStyle.None; container.Add(_agentBadge); + // Unseen-news badge (accent orange, like a mobile app icon badge) + _newsBadge = new Label(); + _newsBadge.style.unityTextAlign = TextAnchor.MiddleCenter; + _newsBadge.style.fontSize = 9; + _newsBadge.style.color = new Color(0.12f, 0.09f, 0.06f); + _newsBadge.style.unityFontStyleAndWeight = FontStyle.Bold; + _newsBadge.style.backgroundColor = kNewsBadgeBg; + _newsBadge.style.borderTopLeftRadius = 6; + _newsBadge.style.borderTopRightRadius = 6; + _newsBadge.style.borderBottomLeftRadius = 6; + _newsBadge.style.borderBottomRightRadius = 6; + _newsBadge.style.paddingLeft = 4; + _newsBadge.style.paddingRight = 4; + _newsBadge.style.paddingTop = 1; + _newsBadge.style.paddingBottom = 1; + _newsBadge.style.marginLeft = 4; + _newsBadge.style.display = DisplayStyle.None; + container.Add(_newsBadge); + // Dropdown arrow var arrow = new Label("\u25BE"); arrow.style.fontSize = 10; @@ -539,6 +606,17 @@ internal static void RefreshMainToolbar() { _agentBadge.style.display = DisplayStyle.None; } + + int unseen = MCPNewsService.UnseenCount; + if (unseen > 0) + { + _newsBadge.text = unseen > 9 ? "9+" : unseen.ToString(); + _newsBadge.style.display = DisplayStyle.Flex; + } + else + { + _newsBadge.style.display = DisplayStyle.None; + } } } #endif diff --git a/package.json b/package.json index dee5f3c..0d6afa5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.anklebreaker.unity-mcp", - "version": "2.37.1", + "version": "2.38.0", "displayName": "AnkleBreaker Unity MCP", "description": "MCP (Model Context Protocol) bridge plugin for Unity Editor. Enables AI assistants like Claude to control Unity Editor via a local HTTP API — manage scenes, GameObjects, components, assets, builds, and more.", "unity": "2021.3", From ab66900286316054dcb6635fb101771f4030ccf9 Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Thu, 23 Jul 2026 11:51:09 +0200 Subject: [PATCH 12/15] [FIX] Args, non-integer numeric params silently dropped on decimal-comma locales (battle-test BUG 1) : new MCPArgs reads MiniJson's boxed double/long typed-first (no ToString round-trip); ProBuilder GetFloat/GetInt/GetBool + UMA GetOptionalFloat delegate to it; present-but-invalid params now throw a clear error instead of silently defaulting; create-shape echoes appliedSize/appliedPosition. Reproduced + verified under forced fr-FR; also closes the create-shape 'race' (BUG 2 = dropped float positions) [FIX] ProBuilder, info bounds unreliable (BUG 3) : local bounds recalculated before reporting + new worldBounds (renderer AABB reflecting the transform); verified on a 2x-scaled cube [FIX] ProBuilder, boolean left operands overlapping the result (BUG 4) : sources deleted by default (Undo-tracked; deleteSources:false to keep), new name param, response reports sourceInstanceIds + sourcesDeleted - Bump package 2.38.0 -> 2.39.0 --- CHANGELOG.md | 9 ++++ Editor/MCPArgs.cs | 85 +++++++++++++++++++++++++++++++++ Editor/MCPArgs.cs.meta | 11 +++++ Editor/MCPProBuilderCommands.cs | 58 ++++++++++++++++++---- Editor/MCPUMACommands.cs | 10 ++-- package.json | 2 +- 6 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 Editor/MCPArgs.cs create mode 100644 Editor/MCPArgs.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e6bcd..bbff99d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this package will be documented in this file. +## [2.39.0] - 2026-07-22 + +### Fixed (Discord battle-test report — "floats silently dropped + 4 more bugs") +- **BUG 1 (MAJOR): non-integer numeric parameters were silently dropped on decimal-comma locales.** Root cause: handlers read numbers via `value.ToString()` (current culture: `18.8` → `"18,8"` on e.g. fr-FR) then parsed with InvariantCulture — fail → silent fallback to the default. Integers and strings were unaffected, which is why it looked like "floats rejected". New shared `MCPArgs` reads the boxed `double`/`long` MiniJson actually delivers **typed-first** (no string round-trip); ProBuilder's `GetFloat`/`GetInt`/`GetBool` and UMA's `GetOptionalFloat` now delegate to it. Reproduced and verified under a forced `fr-FR` culture; the reporter's exact case (a 1.4 × 0.72 × 0.7 cube) now applies verbatim, and float `translate_faces` genuinely moves vertices. +- **Design rule from the report — no silent fallback, no silent success:** a parameter that is present but not interpretable now **throws a clear error** (`Parameter 'width' is not a valid number: 'abc'`) instead of defaulting, and `create-shape` echoes the **actually applied** `appliedSize`/`appliedPosition` so a dropped value is visible in the response. +- **BUG 2 (create-shape "race"): same root cause as BUG 1** — the "lost" positions (12.8 / 15.2) were non-integer and got dropped; the queue serializes writes one per frame and `CreateShape` reads only its own arguments. Verified: 3 concurrent creates with float positions all land exactly where requested. +- **BUG 3: `probuilder/info` bounds unreliable** — local mesh bounds are now recalculated before reporting (stale after vertex edits), and the response adds **`worldBounds`** (renderer AABB — reflects transform scale/rotation/position, which is what placement logic actually needs). Verified: a 2×-scaled cube reports local 1.4×0.97×0.7 and world 2.8×1.94×1.4. +- **BUG 4: `probuilder/boolean` left both operands overlapping the result** — sources are now **deleted by default** (Undo-tracked, one undo restores everything); pass `deleteSources:false` to keep them. New `name` parameter for the result; the response reports `sourceInstanceIds` + `sourcesDeleted`. + ## [2.38.0] - 2026-07-19 ### Added (studio news notifications) diff --git a/Editor/MCPArgs.cs b/Editor/MCPArgs.cs new file mode 100644 index 0000000..6a153ad --- /dev/null +++ b/Editor/MCPArgs.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace UnityMCP.Editor +{ + /// + /// Typed-first argument readers for command handlers. + /// + /// MiniJson delivers JSON numbers as boxed double / long. The old + /// pattern value.ToString() + invariant TryParse silently DROPPED every + /// non-integer number on machines whose current culture writes a decimal comma + /// (18.8 → "18,8" → parse fail → default) — the exact "floats silently ignored" + /// bug from the battle-test report. Readers here unbox the typed value directly + /// and only fall back to invariant string parsing for string inputs. + /// + /// Design rule (also from the report): a parameter that is PRESENT but not + /// interpretable throws instead of silently falling back to the default — + /// a loud failure costs seconds, a silent wrong default costs a debugging session. + /// + internal static class MCPArgs + { + public static float GetFloat(Dictionary args, string key, float defaultValue) + { + if (args == null || !args.TryGetValue(key, out object value) || value == null) + return defaultValue; + + switch (value) + { + case double d: return (float)d; + case float f: return f; + case long l: return l; + case int i: return i; + case string s: + if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsed)) + return parsed; + throw new ArgumentException($"Parameter '{key}' is not a valid number: '{s}'."); + default: + throw new ArgumentException($"Parameter '{key}' must be a number (got {value.GetType().Name})."); + } + } + + public static int GetInt(Dictionary args, string key, int defaultValue) + { + if (args == null || !args.TryGetValue(key, out object value) || value == null) + return defaultValue; + + switch (value) + { + case long l: return checked((int)l); + case int i: return i; + case double d: + // Accept integral doubles (8.0) — reject genuinely fractional values loudly. + double rounded = Math.Round(d); + if (Math.Abs(d - rounded) < 1e-6) return checked((int)rounded); + throw new ArgumentException($"Parameter '{key}' must be an integer (got {d.ToString(CultureInfo.InvariantCulture)})."); + case string s: + if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed)) + return parsed; + throw new ArgumentException($"Parameter '{key}' is not a valid integer: '{s}'."); + default: + throw new ArgumentException($"Parameter '{key}' must be an integer (got {value.GetType().Name})."); + } + } + + public static bool GetBool(Dictionary args, string key, bool defaultValue) + { + if (args == null || !args.TryGetValue(key, out object value) || value == null) + return defaultValue; + + switch (value) + { + case bool b: return b; + case string s: + string lower = s.ToLowerInvariant(); + if (lower == "true" || lower == "1") return true; + if (lower == "false" || lower == "0") return false; + throw new ArgumentException($"Parameter '{key}' is not a valid boolean: '{s}'."); + case long l: return l != 0; + default: + throw new ArgumentException($"Parameter '{key}' must be a boolean (got {value.GetType().Name})."); + } + } + } +} diff --git a/Editor/MCPArgs.cs.meta b/Editor/MCPArgs.cs.meta new file mode 100644 index 0000000..ddb7f9b --- /dev/null +++ b/Editor/MCPArgs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d2c8e7a4f9b41c3a6e8d0b2f1c74604 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MCPProBuilderCommands.cs b/Editor/MCPProBuilderCommands.cs index 3fac62b..323b2c0 100644 --- a/Editor/MCPProBuilderCommands.cs +++ b/Editor/MCPProBuilderCommands.cs @@ -93,7 +93,15 @@ public static object CreateShape(Dictionary args) Rebuild(pb); Undo.RegisterCreatedObjectUndo(go, "Create ProBuilder " + shape); - return Ok(pb, new Dictionary { { "shape", shape }, { "name", go.name } }); + // Echo the ACTUALLY-applied dimensions/position so a dropped or misparsed + // param is visible in the response instead of silently defaulting (battle-test rule). + return Ok(pb, new Dictionary + { + { "shape", shape }, + { "name", go.name }, + { "appliedSize", V(size) }, + { "appliedPosition", V(go.transform.position) }, + }); } // ───────────────────────────────────────────── @@ -103,7 +111,20 @@ public static object CreateShape(Dictionary args) public static object GetInfo(Dictionary args) { if (!TryResolve(args, out var pb, out var err)) return err; - var b = pb.GetComponent()?.sharedMesh != null ? pb.GetComponent().sharedMesh.bounds : new Bounds(); + + // Local bounds can be stale after vertex edits — recalculate before reporting + // (battle-test BUG 3), and report the world AABB too: local bounds never reflect + // the transform (scale/rotation), which is what agents actually place against. + var mf = pb.GetComponent(); + var b = new Bounds(); + if (mf != null && mf.sharedMesh != null) + { + mf.sharedMesh.RecalculateBounds(); + b = mf.sharedMesh.bounds; + } + var mr = pb.GetComponent(); + var world = mr != null ? mr.bounds : new Bounds(pb.transform.position, Vector3.zero); + var materials = pb.faces.Select(f => f.submeshIndex).Distinct().OrderBy(i => i).ToArray(); return new Dictionary { @@ -118,6 +139,8 @@ public static object GetInfo(Dictionary args) { "submeshCount", materials.Length }, { "bounds", new Dictionary { { "center", V(b.center) }, { "size", V(b.size) } } }, + { "worldBounds", new Dictionary { + { "center", V(world.center) }, { "size", V(world.size) } } }, }; } @@ -252,9 +275,14 @@ public static object BooleanOp(Dictionary args) if (mesh == null) return Error(csgErr ?? "Boolean operation produced no mesh (are both objects solid meshes?)."); var result = mesh; + string sourceAId = MCPObjectId.Get(a); + string sourceBId = MCPObjectId.Get(b); + // CSG output vertices are in WORLD space — the result object must sit at the // identity transform (setting it to an operand's position double-offsets the mesh). - var go = new GameObject($"PB_Boolean_{op}"); + var go = new GameObject(args.ContainsKey("name") && args["name"] != null + ? args["name"].ToString() + : $"PB_Boolean_{op}"); var mf = go.AddComponent(); mf.sharedMesh = result; var mr = go.AddComponent(); @@ -275,6 +303,16 @@ public static object BooleanOp(Dictionary args) } Undo.RegisterCreatedObjectUndo(go, "ProBuilder Boolean"); + // The result fully replaces the operands' volume — leaving both sources alive + // and overlapping it surprised every agent in the battle test (BUG 4). Default + // is now to remove them (Undo-tracked); pass deleteSources:false to keep them. + bool deleteSources = GetBool(args, "deleteSources", true); + if (deleteSources) + { + Undo.DestroyObjectImmediate(a); + Undo.DestroyObjectImmediate(b); + } + var boolResult = new Dictionary { { "success", true }, @@ -283,6 +321,8 @@ public static object BooleanOp(Dictionary args) { "instanceId", MCPObjectId.Get(go) }, { "vertexCount", result.vertexCount }, { "editableProBuilder", imported }, + { "sourceInstanceIds", new List { sourceAId, sourceBId } }, + { "sourcesDeleted", deleteSources }, }; if (materialsDefaulted) boolResult["materialsDefaulted"] = true; return boolResult; @@ -519,15 +559,15 @@ private static Dictionary V(Vector3 v) => #endif // ─── Shared arg helpers (available in both branches) ─── + // Typed-first via MCPArgs: the old value.ToString() + invariant-parse round-trip + // silently dropped every non-integer number on decimal-comma locales (battle-test + // BUG 1); present-but-invalid params now throw instead of defaulting silently. private static object Error(string msg) => new Dictionary { { "error", msg } }; - private static float GetFloat(Dictionary a, string k, float def) => - a != null && a.ContainsKey(k) && a[k] != null && float.TryParse(a[k].ToString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var f) ? f : def; + private static float GetFloat(Dictionary a, string k, float def) => MCPArgs.GetFloat(a, k, def); - private static int GetInt(Dictionary a, string k, int def) => - a != null && a.ContainsKey(k) && a[k] != null && int.TryParse(a[k].ToString(), out var i) ? i : def; + private static int GetInt(Dictionary a, string k, int def) => MCPArgs.GetInt(a, k, def); - private static bool GetBool(Dictionary a, string k, bool def) => - a != null && a.ContainsKey(k) && a[k] != null && (a[k].ToString().ToLowerInvariant() == "true" || a[k].ToString() == "1") ? true : (a != null && a.ContainsKey(k) && a[k] != null ? false : def); + private static bool GetBool(Dictionary a, string k, bool def) => MCPArgs.GetBool(a, k, def); } } diff --git a/Editor/MCPUMACommands.cs b/Editor/MCPUMACommands.cs index 3a0f0f1..bf02dee 100644 --- a/Editor/MCPUMACommands.cs +++ b/Editor/MCPUMACommands.cs @@ -3200,13 +3200,9 @@ private static string GetGameObjectPath(GameObject go) private static float GetOptionalFloat(Dictionary args, string key, float defaultValue) { - if (args.ContainsKey(key) && args[key] != null) - { - if (float.TryParse(args[key].ToString(), System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out float val)) - return val; - } - return defaultValue; + // Typed-first read — the old ToString() round-trip dropped non-integer numbers + // on decimal-comma locales (same class as battle-test BUG 1). + return MCPArgs.GetFloat(args, key, defaultValue); } private static void EnsureFolderExists(string folderPath) diff --git a/package.json b/package.json index 0d6afa5..b804594 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.anklebreaker.unity-mcp", - "version": "2.38.0", + "version": "2.39.0", "displayName": "AnkleBreaker Unity MCP", "description": "MCP (Model Context Protocol) bridge plugin for Unity Editor. Enables AI assistants like Claude to control Unity Editor via a local HTTP API — manage scenes, GameObjects, components, assets, builds, and more.", "unity": "2021.3", From fb018342ce44cb48f39989bd313fdb6311a09790 Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Thu, 23 Jul 2026 15:51:34 +0200 Subject: [PATCH 13/15] =?UTF-8?q?[FIX]=20News,=20merge-audit=20security=20?= =?UTF-8?q?hardening=20of=20the=20devlog=20feed=20:=20-=20Confine=20feed?= =?UTF-8?q?=20links=20to=20http/https=20before=20Application.OpenURL=20(OS?= =?UTF-8?q?=20shell):=20a=20spoofed/compromised=20feed=20could=20smuggle?= =?UTF-8?q?=20file://,=20UNC=20or=20a=20URI-scheme=20handler;=20validated?= =?UTF-8?q?=20at=20parse=20(bad-scheme=20item=20never=20becomes=20a=20clic?= =?UTF-8?q?kable=20post)=20+=20defense-in-depth=20re-check=20in=20OpenPost?= =?UTF-8?q?.=20Live-verified=20file://,=20javascript:,=20steam:,=20ms-msdt?= =?UTF-8?q?:,=20UNC,=20vscode:=20all=20dropped=20incl.=20a=20crafted=20fil?= =?UTF-8?q?e://=20item=20with=20a=20disguised=20=20title=20-=20Stri?= =?UTF-8?q?p=20<>/=20from=20feed=20title/category=20(UI=20Toolkit=20labels?= =?UTF-8?q?=20render=20rich=20text;=20/=20is=20a=20GenericMenu=20separator?= =?UTF-8?q?)=20+=20enableRichText=3Dfalse=20on=20news=20labels=20=E2=80=94?= =?UTF-8?q?=20blocks=20impersonation=20of=20plugin=20UI=20-=20Bound=20inge?= =?UTF-8?q?stion:=20reject=20>1MB=20responses,=20cap=2050=20posts=20(no=20?= =?UTF-8?q?unbounded=20alloc=20/=20EditorPrefs=20blob)=20-=20Strip=20';'?= =?UTF-8?q?=20(the=20seen-set=20delimiter)=20from=20slugs=20so=20a=20craft?= =?UTF-8?q?ed=20slug=20can't=20corrupt=20read-state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [FIX] SelfTest : - Resume-after-domain-reload used delayCall (droppable during InitializeOnLoad) -> now first EditorApplication.update; new ProBuilder probe (create/verify/destroy cube when installed, pass-through when absent). Full battery 44/45 pass, 0 fail (mppm has no probe) - Bump package 2.39.0 -> 2.39.1 --- CHANGELOG.md | 11 ++++++ Editor/MCPDashboardWindow.cs | 6 ++-- Editor/MCPNewsService.cs | 66 +++++++++++++++++++++++++++++++++--- Editor/MCPSelfTest.cs | 56 ++++++++++++++++++++++++++++-- README.md | 10 ++++++ package.json | 2 +- 6 files changed, 142 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbff99d..100527d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this package will be documented in this file. +## [2.39.1] - 2026-07-23 + +### Security (merge-readiness audit of the news feature) +- **News feed links are now confined to http/https before reaching the OS shell.** `MCPNewsService` parsed the devlog RSS `` and handed it verbatim to `Application.OpenURL` (which routes through the OS shell, i.e. any registered URI-scheme handler, `file://`, UNC paths). A spoofed/compromised feed could have turned one click in the News panel into a file/handler open. Links are now validated (`Uri` absolute + `http`/`https` only) at parse time — a bad-scheme item never becomes a clickable post — with a defense-in-depth re-check in `OpenPost`. Verified live: `file://`, `javascript:`, `steam://`, `ms-msdt:`, UNC and `vscode://` links are all dropped; a crafted `file://` item with a disguised `` title is discarded while legit posts survive. +- **Feed text can no longer inject UI markup.** Titles/categories are stripped of `<`/`>` (UI Toolkit labels render rich text by default) and `/` (a `GenericMenu` submenu separator) at parse; the dashboard news labels also set `enableRichText = false`. Prevents a compromised feed from styling an entry to impersonate official plugin UI. +- **Bounded feed ingestion** — responses over 1 MB are rejected and at most 50 posts are parsed, so an oversized/malicious response can't drive unbounded allocation or a giant EditorPrefs write on the main thread. +- **Seen-set integrity** — `;` (the EditorPrefs delimiter) is stripped from slugs so a crafted slug can't corrupt the read-state store. + +### Fixed +- **Self-test resume after domain reload** — a mid-battery domain reload resumed via `EditorApplication.delayCall`, which can be dropped during `InitializeOnLoad`; it now resumes on the first `EditorApplication.update` (always fires). New **ProBuilder self-test probe** (creates/verifies/destroys a cube when ProBuilder is installed, passes through when absent). + ## [2.39.0] - 2026-07-22 ### Fixed (Discord battle-test report — "floats silently dropped + 4 more bugs") diff --git a/Editor/MCPDashboardWindow.cs b/Editor/MCPDashboardWindow.cs index adcfbbe..8e42488 100644 --- a/Editor/MCPDashboardWindow.cs +++ b/Editor/MCPDashboardWindow.cs @@ -309,7 +309,9 @@ private void RefreshNews() if (MCPNewsService.IsUnseen(post)) item.AddToClassList("ab-news__item--unseen"); - var title = new Label(post.Title); + // Feed-derived text: disable rich text so markup in a title can never render + // (defense-in-depth — MCPNewsService already strips angle brackets at parse). + var title = new Label(post.Title) { enableRichText = false }; title.AddToClassList("ab-news__title"); item.Add(title); @@ -317,7 +319,7 @@ private void RefreshNews() if (!string.IsNullOrEmpty(post.Category)) { - var chip = new Label(post.Category); + var chip = new Label(post.Category) { enableRichText = false }; chip.AddToClassList("ab-chip"); item.Add(chip); } diff --git a/Editor/MCPNewsService.cs b/Editor/MCPNewsService.cs index 5b0ace9..32174dc 100644 --- a/Editor/MCPNewsService.cs +++ b/Editor/MCPNewsService.cs @@ -31,6 +31,11 @@ public static class MCPNewsService private const double CheckIntervalHours = 6.0; private const int MaxSeenSlugs = 100; + // Bounds on remote feed content: a compromised/oversized response must not be able + // to OOM the editor or write a huge blob to EditorPrefs (registry-backed) on the main thread. + private const int MaxPosts = 50; + private const int MaxTitleLength = 200; + private const int MaxFeedBytes = 1_048_576; // 1 MB — the devlog feed is a few KB public sealed class Post { @@ -108,6 +113,14 @@ public static void MarkAllSeen() public static void OpenPost(Post post) { if (post == null || string.IsNullOrEmpty(post.Url)) return; + // Defense-in-depth: even though ParseFeed already rejects non-web links, never + // hand anything but a validated http/https URL to the OS shell via OpenURL. + if (!IsSafeWebUrl(post.Url)) + { + Debug.LogWarning($"[AB-UMCP] Refusing to open non-web news URL: {post.Url}"); + MarkSeen(post); + return; + } Application.OpenURL(post.Url); MarkSeen(post); } @@ -155,6 +168,14 @@ private static void OnFeedDone(UnityWebRequest req) return; } + // Bound the body before parsing — an oversized response must not drive + // unbounded allocation or a giant EditorPrefs write. The real feed is a few KB. + if (xml.Length > MaxFeedBytes) + { + LastError = "Feed response too large"; + return; + } + List parsed = ParseFeed(xml); if (parsed.Count == 0) { @@ -192,7 +213,7 @@ private static List ParseFeed(string xml) { var posts = new List(); int cursor = 0; - while (true) + while (posts.Count < MaxPosts) { int start = xml.IndexOf("", cursor, StringComparison.Ordinal); if (start < 0) break; @@ -202,16 +223,22 @@ private static List ParseFeed(string xml) cursor = end + 7; string link = StripCData(Between(item, "", "")); - string title = Decode(StripCData(Between(item, "", ""))); + string title = SanitizeText(Decode(StripCData(Between(item, "", "")))); if (string.IsNullOrEmpty(link) || string.IsNullOrEmpty(title)) continue; link = link.Trim(); + // SECURITY: only accept http/https links. The link is later handed to + // Application.OpenURL (the OS shell) — a compromised or spoofed feed must + // not be able to smuggle file://, a UNC path, or an OS URI-scheme handler. + // Rejecting here means such an item never becomes a clickable Post at all. + if (!IsSafeWebUrl(link)) continue; + var post = new Post { Title = title, Url = link, Slug = SlugOf(link), - Category = Decode(StripCData(Between(item, "", ""))) ?? "", + Category = SanitizeText(Decode(StripCData(Between(item, "", "")))) ?? "", }; string pub = StripCData(Between(item, "", "")); @@ -227,11 +254,42 @@ private static List ParseFeed(string xml) return posts; } + /// True only for well-formed absolute http/https URLs. + internal static bool IsSafeWebUrl(string url) + { + return !string.IsNullOrEmpty(url) + && Uri.TryCreate(url, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + } + + /// + /// Neutralize remote feed text before it reaches UI Toolkit / GenericMenu: + /// strip rich-text markup (labels interpret <color>/<b> by default) and + /// the '/' GenericMenu submenu separator; collapse to a bounded single line. + /// + private static string SanitizeText(string value) + { + if (string.IsNullOrEmpty(value)) return value; + var sb = new System.Text.StringBuilder(value.Length); + foreach (char c in value) + { + if (c == '<' || c == '>') continue; // rich-text tags + if (c == '/') { sb.Append('⁄'); continue; } // GenericMenu separator → fraction slash + if (c == '\r' || c == '\n' || c == '\t') { sb.Append(' '); continue; } + sb.Append(c); + } + string cleaned = sb.ToString().Trim(); + return cleaned.Length > MaxTitleLength ? cleaned.Substring(0, MaxTitleLength - 1) + "…" : cleaned; + } + private static string SlugOf(string url) { string trimmed = url.TrimEnd('/'); int slash = trimmed.LastIndexOf('/'); - return slash >= 0 ? trimmed.Substring(slash + 1) : trimmed; + string slug = slash >= 0 ? trimmed.Substring(slash + 1) : trimmed; + // The seen-set is ';'-delimited in EditorPrefs — a ';' in a slug would split + // one entry into two on reload and never round-trip. Drop the delimiter. + return slug.Replace(";", ""); } private static string Between(string source, string startTag, string endTag) diff --git a/Editor/MCPSelfTest.cs b/Editor/MCPSelfTest.cs index 21bef43..f68e7d7 100644 --- a/Editor/MCPSelfTest.cs +++ b/Editor/MCPSelfTest.cs @@ -111,11 +111,26 @@ static MCPSelfTest() if (resumeIndex >= 0) { Debug.Log($"[MCP SelfTest] Domain reload detected mid-run — resuming from index {resumeIndex}"); - // Delay resume by one frame to let the editor finish initializing - EditorApplication.delayCall += () => ResumeFromIndex(resumeIndex); + // Resume on the first editor update. delayCall registered during + // InitializeOnLoad can be silently dropped by the reload (observed: a + // battery interrupted at the asmdef/asset recompile never resumed); + // an update subscription always fires. + _pendingResumeIndex = resumeIndex; + EditorApplication.update += ResumePendingOnce; } } + private static int _pendingResumeIndex = -1; + + private static void ResumePendingOnce() + { + EditorApplication.update -= ResumePendingOnce; + int index = _pendingResumeIndex; + _pendingResumeIndex = -1; + if (index >= 0) + ResumeFromIndex(index); + } + // ─── Persistence helpers ───────────────────────────────────── /// Tracks current step index for domain-reload resume. -1 = not running. @@ -233,6 +248,7 @@ private static void RestoreFromSession() { "navigation", TestNavigation }, { "packagemanager", TestPackageManager }, { "particle", TestParticle }, + { "probuilder", TestProBuilder }, { "prefabasset", TestPrefabAsset }, { "prefs", TestPrefs }, { "projectsettings",TestProjectSettings }, @@ -1167,6 +1183,42 @@ private static string TestUI() } } + // --- ProBuilder --- + private static string TestProBuilder() + { +#if PROBUILDER_INSTALLED + GameObject probe = null; + try + { + var args = new Dictionary { { "shape", "cube" }, { "name", "__mcp_selftest_pb" } }; + var result = MCPProBuilderCommands.CreateShape(args) as Dictionary; + if (result == null || !result.ContainsKey("success")) + return "ProBuilder.CreateShape returned no success payload"; + probe = GameObject.Find("__mcp_selftest_pb"); + if (probe == null) + return "ProBuilder.CreateShape did not create the probe object"; + if (!result.ContainsKey("faceCount") || Convert.ToInt32(result["faceCount"]) != 6) + return "ProBuilder cube probe has unexpected face count"; + return null; + } + catch (Exception ex) + { + return $"ProBuilder.CreateShape threw: {ex.Message}"; + } + finally + { + if (probe != null) UnityEngine.Object.DestroyImmediate(probe); + else + { + var leftover = GameObject.Find("__mcp_selftest_pb"); + if (leftover != null) UnityEngine.Object.DestroyImmediate(leftover); + } + } +#else + return null; // ProBuilder not installed — pass (handler not compiled) +#endif + } + // --- UMA --- private static string TestUMA() { diff --git a/README.md b/README.md index 8bd9a86..d0cc4fa 100755 --- a/README.md +++ b/README.md @@ -247,6 +247,16 @@ If Unity MCP helps your workflow, consider supporting its development! Your supp **Sponsor tiers include priority feature requests** — your ideas get bumped up the roadmap! Check out the tiers on [GitHub Sponsors](https://github.com/sponsors/AnkleBreaker-Studio) or [Patreon](https://www.patreon.com/AnkleBreakerStudio). +## What's New in v2.33.0 → v2.39.1 + +- **ProBuilder integration** — 14 `probuilder/*` commands over ProBuilder's real API (parametric shapes, face edits, boolean CSG with source cleanup + naming, combine, probuilderize, mesh export), gated on `PROBUILDER_INSTALLED` so projects without ProBuilder get a clear message instead of a compile error. Every mutation is Undo-tracked. +- **Per-action / per-agent undo** — the queue wraps every write in its own named Undo group; `undo/last` reverts a whole action (per-agent aware, linear-undo honest), `undo/history` is a per-agent action log. +- **Studio news notifications** — a mobile-style unseen badge on the MCP toolbar element for new AnkleBreaker devlog posts (6h RSS poll, per-user read state, opt-out in Settings), news in the toolbar dropdown and the dashboard. +- **Dashboard reworked in UI Toolkit** — Window → AB Unity MCP rebuilt on the studio theme (shared brand stylesheet), preserving every feature and adding the news panel; sections self-heal and refresh on content change only. +- **Correctness** — locale-proof numeric args (non-integer params were silently dropped on decimal-comma locales; present-but-invalid now errors loudly, `create-shape` echoes applied size/position), `probuilder/info` world bounds, dense `scene/hierarchy` (absent field = default; `verbose:true` restores), scene-capture camera sync for backgrounded editors. +- **Data safety** — shared path confinement (`Assets/`/`Packages/` only), overwrite guards on every asset creator (`overwrite:true` to replace), script create/update source-loss vectors closed, queue drops timed-out tickets before execution (no double-runs), ShaderGraph create/edit through the real `GraphData` model (all four corruption bugs from #18 fixed). +- **Generated route registry** — `MCPBridgeServer.Routes.g.cs` generated from the dispatch switch with a CI drift check (337 routes). + ## What's New in v2.32.0 - **Editor-window screenshots (`screenshot/editor-window`)** — capture any EditorWindow (Inspector, Project, Console, custom windows) to a PNG via the Win32 `PrintWindow` API. Occlusion-proof (the window renders itself offscreen — no raising or focus-stealing), with automatic docked-vs-floating handling. Defaults to `Assets/Screenshots/`, accepts any `.png` path. **Windows editor only** (`#if UNITY_EDITOR_WIN`) — on macOS/Linux it returns a clear "unsupported platform" error (PrintWindow has no equivalent there); use scene/game capture, which are camera-based and cross-platform. Companion to `unity-mcp-server` v2.30.0. diff --git a/package.json b/package.json index b804594..564a993 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.anklebreaker.unity-mcp", - "version": "2.39.0", + "version": "2.39.1", "displayName": "AnkleBreaker Unity MCP", "description": "MCP (Model Context Protocol) bridge plugin for Unity Editor. Enables AI assistants like Claude to control Unity Editor via a local HTTP API — manage scenes, GameObjects, components, assets, builds, and more.", "unity": "2021.3", From 633420f2735ae7c8b4854cf6bb9a1fd946b61346 Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Thu, 23 Jul 2026 16:12:38 +0200 Subject: [PATCH 14/15] [FIX] Audit, pre-merge multi-expert review blockers : - CRITICAL asmdef path confinement: all 7 asmdef/* handlers took raw 'path' into File.Read/WriteAllText (the one asset-writer the data-safety wave missed) -> route through MCPAssetSafety.TryResolveProjectPath; live-verified ../../hosts and C:/.../evil.asmdef rejected, normal create/info/ref still work - CRITICAL deferred-route self-deadlock: a direct (non queue/submit) POST to a deferred route froze the editor for the full sync timeout -> return 409 directing to queue/submit; remove the orphaned ExecuteOnMainThreadDeferred primitive. Async queue path (what the server uses) unaffected - ProBuilder combine now records the surviving target before merge -> undo/last fully reverts (target geometry + sources); live-verified 6->12->6 - Deferred tickets no longer open an undo group (their late collapse could fold a concurrent agent's group in) - shadergraph set-node-property: real error when the property is absent (was silent success on an unchanged file) + literal MatchEvaluator so a $ in the value can't splice captured text - ProBuilder null-arg guards (name/material/faceIndices elem), execute-code dict item-cap, toolbar dot-texture cleanup before reload, dead var removed - Bump 2.39.1 -> 2.39.2 --- CHANGELOG.md | 10 +++++ Editor/MCPAssemblyDefCommands.cs | 71 +++++++++++++++++++++++--------- Editor/MCPBridgeServer.cs | 59 ++++---------------------- Editor/MCPEditorCommands.cs | 7 ++++ Editor/MCPProBuilderCommands.cs | 14 ++++--- Editor/MCPRequestQueue.cs | 11 +++-- Editor/MCPShaderGraphCommands.cs | 26 ++++++++---- Editor/MCPToolbarElement.cs | 10 +++++ package.json | 2 +- 9 files changed, 122 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 100527d..95c38ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this package will be documented in this file. +## [2.39.2] - 2026-07-23 + +### Security / Fixed (pre-merge multi-expert audit) +- **CRITICAL — asmdef commands now confined to the project.** Every `asmdef/*` handler (`create`, `info`, `add-references`, `remove-references`, `set-platforms`, `update-settings`, `create-ref`) took the caller's raw `path` straight into `File.ReadAllText`/`WriteAllText` — the one asset-writing surface the data-safety wave hadn't retrofitted. A `../../` or absolute `path` could read/write outside the project. All seven now resolve through `MCPAssetSafety.TryResolveProjectPath` (traversal + absolute rejected); live-verified that `../../…/hosts` and `C:/Users/Public/evil.asmdef` are refused while a normal `Assets/…​.asmdef` create/info/reference still works. +- **CRITICAL — legacy synchronous endpoint no longer self-deadlocks on deferred routes.** A direct (non-`queue/submit`) POST to a deferred route (`testing/list-tests`) blocked the editor for the full sync timeout (the main-thread pump can't drain while the same update tick is blocked). Such routes now return a clear 409 directing to `queue/submit`; the orphaned `ExecuteOnMainThreadDeferred` deadlock primitive is removed. The async queue path (what the server uses) is unaffected. +- **`probuilder/combine` is now fully undoable** — it didn't record the surviving target before merging, so `undo/last`/Ctrl+Z restored the consumed sources but left the target merged (partial undo). Verified: after undo the target returns to its pre-merge face count AND the sources come back. +- **Deferred tickets no longer open an undo group** — their collapse fires an arbitrary number of frames later and could fold a concurrent agent's undo group into theirs, corrupting per-action undo. Deferred actions (already excluded from history) now never open a group. +- **`shadergraph/set-node-property` no longer reports success when it changed nothing** — a missing property now returns a clear error instead of `success:true` on an unchanged file; the value is written through a literal `MatchEvaluator` so a `$`/`$1` in the value can't splice in captured regex text. +- **ProBuilder null-arg hardening** — explicit-JSON `null` for `name`/`material`/a `faceIndices` element now yields a clear validation error instead of a `NullReferenceException`; `execute-code` dictionary results are item-capped like lists; toolbar dot textures are freed before domain reload (no per-recompile native-texture leak); dead local removed from `translate-faces`. + ## [2.39.1] - 2026-07-23 ### Security (merge-readiness audit of the news feature) diff --git a/Editor/MCPAssemblyDefCommands.cs b/Editor/MCPAssemblyDefCommands.cs index 826f5d0..cc16b9c 100644 --- a/Editor/MCPAssemblyDefCommands.cs +++ b/Editor/MCPAssemblyDefCommands.cs @@ -27,14 +27,18 @@ public static object CreateAssemblyDef(Dictionary args) if (!path.EndsWith(".asmdef")) path += ".asmdef"; + // Confine to Assets/|Packages/ — reject traversal/absolute writes (data-safety). + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; + string asmName = args.ContainsKey("name") ? args["name"].ToString() : Path.GetFileNameWithoutExtension(path); - // Ensure directory exists + // Ensure directory exists (asset-relative path — the confined path is validated above) EnsureDirectoryExists(path); // Check if file already exists - if (File.Exists(path)) + if (File.Exists(fullPath)) return new { error = $"Assembly definition already exists at '{path}'. Use asmdef/info to inspect or asmdef/update to modify it." }; // Build the asmdef JSON @@ -55,7 +59,7 @@ public static object CreateAssemblyDef(Dictionary args) }; string json = FormatAsmdefJson(asmdef); - File.WriteAllText(path, json); + File.WriteAllText(fullPath, json); AssetDatabase.ImportAsset(path); return new @@ -78,10 +82,15 @@ public static object GetAssemblyDefInfo(Dictionary args) if (string.IsNullOrEmpty(path)) return new { error = "path is required" }; - if (!File.Exists(path)) + // Confine to Assets/|Packages/ — reject traversal/absolute paths so a raw asmdef + // 'path' can't read/write files outside the project (data-safety, matches siblings). + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; + + if (!File.Exists(fullPath)) return new { error = $"File not found: '{path}'" }; - string json = File.ReadAllText(path); + string json = File.ReadAllText(fullPath); var asmdef = MiniJson.Deserialize(json) as Dictionary; if (asmdef == null) return new { error = "Failed to parse assembly definition JSON" }; @@ -170,14 +179,19 @@ public static object AddReferences(Dictionary args) if (string.IsNullOrEmpty(path)) return new { error = "path is required" }; - if (!File.Exists(path)) + // Confine to Assets/|Packages/ — reject traversal/absolute paths so a raw asmdef + // 'path' can't read/write files outside the project (data-safety, matches siblings). + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; + + if (!File.Exists(fullPath)) return new { error = $"File not found: '{path}'" }; var newRefs = BuildStringList(args, "references"); if (newRefs.Count == 0) return new { error = "references array is required and must not be empty" }; - string json = File.ReadAllText(path); + string json = File.ReadAllText(fullPath); var asmdef = MiniJson.Deserialize(json) as Dictionary; if (asmdef == null) return new { error = "Failed to parse assembly definition JSON" }; @@ -208,7 +222,7 @@ public static object AddReferences(Dictionary args) asmdef["references"] = existingRefs.Cast().ToList(); json = FormatAsmdefJson(asmdef); - File.WriteAllText(path, json); + File.WriteAllText(fullPath, json); AssetDatabase.ImportAsset(path); return new @@ -232,14 +246,19 @@ public static object RemoveReferences(Dictionary args) if (string.IsNullOrEmpty(path)) return new { error = "path is required" }; - if (!File.Exists(path)) + // Confine to Assets/|Packages/ — reject traversal/absolute paths so a raw asmdef + // 'path' can't read/write files outside the project (data-safety, matches siblings). + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; + + if (!File.Exists(fullPath)) return new { error = $"File not found: '{path}'" }; var refsToRemove = BuildStringList(args, "references"); if (refsToRemove.Count == 0) return new { error = "references array is required and must not be empty" }; - string json = File.ReadAllText(path); + string json = File.ReadAllText(fullPath); var asmdef = MiniJson.Deserialize(json) as Dictionary; if (asmdef == null) return new { error = "Failed to parse assembly definition JSON" }; @@ -281,7 +300,7 @@ public static object RemoveReferences(Dictionary args) asmdef["references"] = existingRefs.Cast().ToList(); json = FormatAsmdefJson(asmdef); - File.WriteAllText(path, json); + File.WriteAllText(fullPath, json); AssetDatabase.ImportAsset(path); return new @@ -304,10 +323,15 @@ public static object SetPlatforms(Dictionary args) if (string.IsNullOrEmpty(path)) return new { error = "path is required" }; - if (!File.Exists(path)) + // Confine to Assets/|Packages/ — reject traversal/absolute paths so a raw asmdef + // 'path' can't read/write files outside the project (data-safety, matches siblings). + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; + + if (!File.Exists(fullPath)) return new { error = $"File not found: '{path}'" }; - string json = File.ReadAllText(path); + string json = File.ReadAllText(fullPath); var asmdef = MiniJson.Deserialize(json) as Dictionary; if (asmdef == null) return new { error = "Failed to parse assembly definition JSON" }; @@ -319,7 +343,7 @@ public static object SetPlatforms(Dictionary args) asmdef["excludePlatforms"] = BuildStringList(args, "excludePlatforms").Cast().ToList(); json = FormatAsmdefJson(asmdef); - File.WriteAllText(path, json); + File.WriteAllText(fullPath, json); AssetDatabase.ImportAsset(path); return new @@ -342,10 +366,15 @@ public static object UpdateSettings(Dictionary args) if (string.IsNullOrEmpty(path)) return new { error = "path is required" }; - if (!File.Exists(path)) + // Confine to Assets/|Packages/ — reject traversal/absolute paths so a raw asmdef + // 'path' can't read/write files outside the project (data-safety, matches siblings). + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; + + if (!File.Exists(fullPath)) return new { error = $"File not found: '{path}'" }; - string json = File.ReadAllText(path); + string json = File.ReadAllText(fullPath); var asmdef = MiniJson.Deserialize(json) as Dictionary; if (asmdef == null) return new { error = "Failed to parse assembly definition JSON" }; @@ -396,7 +425,7 @@ public static object UpdateSettings(Dictionary args) return new { error = "No settings to update. Provide at least one of: name, rootNamespace, allowUnsafeCode, overrideReferences, autoReferenced, noEngineReferences, defineConstraints, precompiledReferences, versionDefines" }; json = FormatAsmdefJson(asmdef); - File.WriteAllText(path, json); + File.WriteAllText(fullPath, json); AssetDatabase.ImportAsset(path); return new @@ -423,13 +452,17 @@ public static object CreateAssemblyRef(Dictionary args) if (!path.EndsWith(".asmref")) path += ".asmref"; + // Confine to Assets/|Packages/ — reject traversal/absolute writes (data-safety). + if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError)) + return new Dictionary { { "error", pathError } }; + string targetAssembly = args.ContainsKey("reference") ? args["reference"].ToString() : ""; if (string.IsNullOrEmpty(targetAssembly)) return new { error = "reference is required (name of the assembly definition to reference, e.g. 'MyGame.Runtime')" }; EnsureDirectoryExists(path); - if (File.Exists(path)) + if (File.Exists(fullPath)) return new { error = $"Assembly reference already exists at '{path}'" }; // Resolve to GUID format @@ -444,7 +477,7 @@ public static object CreateAssemblyRef(Dictionary args) // Pretty-print manually json = json.Replace("{", "{\n ").Replace("}", "\n}"); - File.WriteAllText(path, json); + File.WriteAllText(fullPath, json); AssetDatabase.ImportAsset(path); return new diff --git a/Editor/MCPBridgeServer.cs b/Editor/MCPBridgeServer.cs index 60b45f5..ccb6c1a 100644 --- a/Editor/MCPBridgeServer.cs +++ b/Editor/MCPBridgeServer.cs @@ -441,12 +441,16 @@ private static void HandleRequest(HttpListenerContext context) } // ═══ Deferred paths (Unity APIs with async callbacks) ═══ - if (_deferredRoutes.TryGetValue(apiPath, out var deferredHandler)) + // These complete via an async main-thread callback and MUST go through the async + // queue (queue/submit → SubmitDeferredRequest, which is non-blocking). Running one + // on this synchronous endpoint self-deadlocks the editor for the full sync timeout: + // the ticket executes on the main thread and blocks on the main-thread pump, which + // can't drain until the current update tick returns — but it's blocked inside it. + // The Node server always uses queue/submit; this guard only trips a raw/legacy + // direct POST, turning a 30s hang into an actionable error. + if (_deferredRoutes.ContainsKey(apiPath)) { - var result = MCPRequestQueue.ExecuteWithTracking(agentId, apiPath, - () => ExecuteOnMainThreadDeferred(resolve => - deferredHandler(ParseJson(body), resolve))); - SendJson(response, 200, result); + SendJson(response, 409, new { error = $"Route '{apiPath}' must be called via the async queue (POST /api/queue/submit), not the synchronous endpoint." }); return; } @@ -1446,51 +1450,6 @@ private static object ExecuteOnMainThread(Func action) return result; } - /// - /// Execute an action on the main thread that completes asynchronously via callback. - /// Unlike ExecuteOnMainThread, the calling thread blocks until the resolve callback - /// is invoked — not when the action returns. Use for Unity APIs whose callbacks - /// fire on a subsequent editor frame (e.g. TestRunnerApi.RetrieveTestList). - /// - private static object ExecuteOnMainThreadDeferred(Action> asyncAction) - { - object result = null; - Exception exception = null; - var resetEvent = new ManualResetEventSlim(false); - - lock (_mainThreadQueue) - { - _mainThreadQueue.Enqueue(() => - { - try - { - asyncAction(r => - { - result = r; - resetEvent.Set(); - }); - } - catch (Exception ex) - { - exception = ex; - resetEvent.Set(); - } - }); - } - - if (!resetEvent.Wait(MCPRequestQueue.SyncTimeoutMs)) - return new { error = $"Timeout waiting for Unity callback after {MCPRequestQueue.SyncTimeoutMs / 1000}s" }; - - if (exception != null) - { - // Trace goes to the editor log only — never to the wire. - Debug.LogError($"[AB-UMCP] Deferred execution failed: {exception.Message}\n{exception.StackTrace}"); - return new { error = exception.Message }; - } - - return result; - } - private static void ProcessMainThreadQueue() { lock (_mainThreadQueue) diff --git a/Editor/MCPEditorCommands.cs b/Editor/MCPEditorCommands.cs index 90d20e6..527be33 100755 --- a/Editor/MCPEditorCommands.cs +++ b/Editor/MCPEditorCommands.cs @@ -512,7 +512,14 @@ private static object SerializeValue(object value, int depth) { var obj = new Dictionary(); foreach (System.Collections.DictionaryEntry entry in dictionary) + { + if (obj.Count >= MaxSerializeItems) + { + obj["_truncated"] = $"... (truncated at {MaxSerializeItems} entries)"; + break; + } obj[entry.Key != null ? entry.Key.ToString() : "null"] = SerializeValue(entry.Value, depth + 1); + } return obj; } diff --git a/Editor/MCPProBuilderCommands.cs b/Editor/MCPProBuilderCommands.cs index 323b2c0..8ae86ba 100644 --- a/Editor/MCPProBuilderCommands.cs +++ b/Editor/MCPProBuilderCommands.cs @@ -79,14 +79,14 @@ public static object CreateShape(Dictionary args) } var go = pb.gameObject; - go.name = args.ContainsKey("name") ? args["name"].ToString() : ("PB_" + shape); + go.name = args.ContainsKey("name") && args["name"] != null ? args["name"].ToString() : ("PB_" + shape); if (args.ContainsKey("position") && args["position"] is Dictionary p) go.transform.position = new Vector3(GetFloat(p, "x", 0), GetFloat(p, "y", 0), GetFloat(p, "z", 0)); // ShapeGenerator leaves the renderer's material NULL — the shape renders magenta // and a null material key crashes ProBuilder's CSG. Always end with a real // material: the explicit arg when given, ProBuilder's default otherwise. - var mat = args.ContainsKey("material") + var mat = args.ContainsKey("material") && args["material"] != null ? AssetDatabase.LoadAssetAtPath(args["material"].ToString()) : null; pb.SetMaterial(pb.faces, mat != null ? mat : BuiltinMaterials.defaultMaterial); @@ -213,8 +213,7 @@ public static object TranslateFaces(Dictionary args) : Vector3.zero; UndoRecord(pb, "Move Faces"); - var indexes = faces.SelectMany(f => f.distinctIndexes).Distinct(); - pb.TranslateVertices(faces.SelectMany(f => f.indexes).Distinct().Select(i => i).ToArray(), d); + pb.TranslateVertices(faces.SelectMany(f => f.indexes).Distinct().ToArray(), d); Rebuild(pb); return Ok(pb, new Dictionary { { "movedFaces", faces.Count }, { "translation", V(d) } }); } @@ -235,7 +234,7 @@ public static object FlipNormals(Dictionary args) public static object SetFaceMaterial(Dictionary args) { if (!TryResolve(args, out var pb, out var err)) return err; - if (!args.ContainsKey("material")) return Error("material (asset path) is required."); + if (!args.ContainsKey("material") || args["material"] == null) return Error("material (asset path) is required."); var mat = AssetDatabase.LoadAssetAtPath(args["material"].ToString()); if (mat == null) return Error($"Material not found: {args["material"]}"); var faces = args.ContainsKey("faceIndices") ? ResolveFaces(pb, args, out var fe) : pb.faces.ToList(); @@ -340,6 +339,9 @@ public static object Combine(Dictionary args) if (pb == null) return Error($"'{o}' is not a ProBuilder object (ProBuilderize it first)."); meshes.Add(pb); } + // Record the surviving target BEFORE merging so undo/last (or Ctrl+Z) restores its + // pre-merge geometry — otherwise only the destroyed sources come back (partial undo). + UndoRecord(meshes[0], "Combine ProBuilder Meshes"); var result = CombineMeshes.Combine(meshes, meshes[0]); foreach (var pb in result) Rebuild(pb); // ProBuilder merges into the first mesh and destroys the rest. @@ -504,7 +506,7 @@ private static List ResolveFaces(ProBuilderMesh pb, Dictionary(); foreach (var o in raw) { - if (!int.TryParse(o.ToString(), out int idx) || idx < 0 || idx >= pb.faceCount) + if (o == null || !int.TryParse(o.ToString(), out int idx) || idx < 0 || idx >= pb.faceCount) { error = Error($"Face index out of range: {o} (mesh has {pb.faceCount} faces)."); return null; } faces.Add(pb.faces[idx]); } diff --git a/Editor/MCPRequestQueue.cs b/Editor/MCPRequestQueue.cs index 34d7a92..922ab86 100644 --- a/Editor/MCPRequestQueue.cs +++ b/Editor/MCPRequestQueue.cs @@ -288,8 +288,14 @@ public static void ProcessNextRequests() // so they never clutter the history or shift group indices out from under a // pending undo. GetCurrentGroup() alone is unreliable — many write ops (e.g. // RegisterCreatedObjectUndo) don't advance it — so we open the group explicitly. + // + // Deferred actions are EXCLUDED: their completion fires an arbitrary number of + // frames later, so a CollapseUndoOperations then would fold ANY other agent's + // interleaved group into this one (corrupting per-action undo bookkeeping). They + // are already excluded from history recording below, so they need no group. bool opensUndoGroup = - !IsReadOperation(ticket.ActionName) + ticket.DeferredAction == null + && !IsReadOperation(ticket.ActionName) && !(ticket.ActionName != null && ticket.ActionName.StartsWith("undo/")); int undoGroup = -1; int undoRecordsBefore = -1; @@ -300,7 +306,6 @@ public static void ProcessNextRequests() undoGroup = UnityEditor.Undo.GetCurrentGroup(); UnityEditor.Undo.SetCurrentGroupName(ticket.ActionName ?? "MCP Action"); } - int deferredUndoGroup = undoGroup; // stable capture for the deferred closure // Deferred actions complete via callback on a future editor frame. if (ticket.DeferredAction != null) @@ -314,8 +319,6 @@ public static void ProcessNextRequests() deferredTicket.Status = RequestStatus.Completed; deferredTicket.CompletedAt = DateTime.UtcNow; deferredTicket.DeferredAction = null; - if (deferredUndoGroup >= 0) - UnityEditor.Undo.CollapseUndoOperations(deferredUndoGroup); lock (_queueLock) { diff --git a/Editor/MCPShaderGraphCommands.cs b/Editor/MCPShaderGraphCommands.cs index 0ece035..9bdcd39 100644 --- a/Editor/MCPShaderGraphCommands.cs +++ b/Editor/MCPShaderGraphCommands.cs @@ -956,8 +956,11 @@ public static object SetGraphNodeProperty(Dictionary args) if (blockId == nodeId) { - // Replace the property value in this block + // Replace the property value in this block. A null result means the + // property wasn't present — surface that instead of a silent success. string modified = SetJsonProperty(block, propertyName, value); + if (modified == null) + return new Dictionary { { "error", $"Property '{propertyName}' not found on node '{nodeId}' (no string/number/boolean field matched). The file was not changed." } }; newBlocks.Add(modified); found = true; } @@ -1256,24 +1259,31 @@ private static string RemoveEdgesForNode(string graphBlock, string nodeId, out i return graphBlock.Substring(0, arrayStart) + newArray + graphBlock.Substring(arrayEnd + 1); } + /// + /// Replace 's value in a MultiJson block. Returns the + /// modified block, or null when the property isn't present (so the caller reports + /// a real error instead of a silent success). The replacement goes through a + /// MatchEvaluator so a '$' in the value is treated literally, not as a regex + /// replacement token ("$1"/"$&" would otherwise splice in the captured old value). + /// private static string SetJsonProperty(string block, string propertyName, string value) { - // Try to find and replace a string property + // String property string strPattern = $"\"{propertyName}\"\\s*:\\s*\"[^\"]*\""; if (System.Text.RegularExpressions.Regex.IsMatch(block, strPattern)) - return System.Text.RegularExpressions.Regex.Replace(block, strPattern, $"\"{propertyName}\": \"{value}\""); + return System.Text.RegularExpressions.Regex.Replace(block, strPattern, _ => $"\"{propertyName}\": \"{value}\""); - // Try numeric property + // Numeric property string numPattern = $"\"{propertyName}\"\\s*:\\s*[\\-0-9.eE]+"; if (System.Text.RegularExpressions.Regex.IsMatch(block, numPattern)) - return System.Text.RegularExpressions.Regex.Replace(block, numPattern, $"\"{propertyName}\": {value}"); + return System.Text.RegularExpressions.Regex.Replace(block, numPattern, _ => $"\"{propertyName}\": {value}"); - // Try boolean property + // Boolean property string boolPattern = $"\"{propertyName}\"\\s*:\\s*(true|false)"; if (System.Text.RegularExpressions.Regex.IsMatch(block, boolPattern)) - return System.Text.RegularExpressions.Regex.Replace(block, boolPattern, $"\"{propertyName}\": {value.ToLower()}"); + return System.Text.RegularExpressions.Regex.Replace(block, boolPattern, _ => $"\"{propertyName}\": {value.ToLower()}"); - return block; // Property not found + return null; // property not found — caller must surface this, not report success } internal static Type ResolveShaderGraphNodeType(string typeName) diff --git a/Editor/MCPToolbarElement.cs b/Editor/MCPToolbarElement.cs index 3e91683..8e8eb3e 100644 --- a/Editor/MCPToolbarElement.cs +++ b/Editor/MCPToolbarElement.cs @@ -147,6 +147,16 @@ internal static string StatusTooltip private static void Initialize() { EditorApplication.update += PeriodicRefresh; + // The cached dot textures are HideAndDontSave native objects; free them before a + // domain reload nulls the statics, otherwise each recompile leaks 4 small textures. + AssemblyReloadEvents.beforeAssemblyReload += DisposeDotTextures; + } + + private static void DisposeDotTextures() + { + foreach (var tex in new[] { _greenDot, _redDot, _yellowDot, _greyDot }) + if (tex != null) UnityEngine.Object.DestroyImmediate(tex); + _greenDot = _redDot = _yellowDot = _greyDot = null; } private static double _nextRefreshTime; diff --git a/package.json b/package.json index 564a993..d921de6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.anklebreaker.unity-mcp", - "version": "2.39.1", + "version": "2.39.2", "displayName": "AnkleBreaker Unity MCP", "description": "MCP (Model Context Protocol) bridge plugin for Unity Editor. Enables AI assistants like Claude to control Unity Editor via a local HTTP API — manage scenes, GameObjects, components, assets, builds, and more.", "unity": "2021.3", From aaa74cb614717058424da35d6bcd2bd3d8c8baab Mon Sep 17 00:00:00 2001 From: Francois Cugy Date: Thu, 23 Jul 2026 21:23:59 +0200 Subject: [PATCH 15/15] =?UTF-8?q?[FIX]=20GameObject=20:=20delete=20guards?= =?UTF-8?q?=20the=20ProBuilder=20shared-mesh=20hazard=20(report=20A1)=20-?= =?UTF-8?q?=20unity=5Fgameobject=5Fdelete=20refuses=20when=20the=20target'?= =?UTF-8?q?s=20runtime=20(non-asset)=20mesh=20is=20still=20referenced=20by?= =?UTF-8?q?=20MeshFilters=20outside=20its=20subtree,=20incl.=20inactive=20?= =?UTF-8?q?objects=20(FindObjectsInactive.Include)=20=E2=80=94=20returns?= =?UTF-8?q?=20requiresForce/sharedWith;=20force:true=20overrides=20-=20Onl?= =?UTF-8?q?y=20runtime=20meshes=20are=20scanned,=20and=20only=20when=20the?= =?UTF-8?q?=20target=20has=20one,=20so=20normal=20asset-mesh=20deletes=20k?= =?UTF-8?q?eep=20the=20fast=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [FIX] ProBuilder : duplicate no longer shares a clone's runtime mesh (report A1) - unity_gameobject_duplicate calls MakeUnique() on each cloned ProBuilderMesh so the clone gets an independent, still-editable mesh; destroying either object no longer blanks the copies - Response reports proBuilderMeshesIsolated [FIX] ProBuilder : create_shape material resolution + no silent failure (report B1/B2) - create_shape and set_face_material share one resolver accepting a full asset path OR a bare name - Unresolved material now yields materialWarning (was a silent fall-back to the default with success:true); an ambiguous bare name makes a deterministic pick disclosed via materialWarning + appliedMaterialPath - Fixes the apparent combine material loss (B1): with per-piece materials actually applied, CombineMeshes.Combine preserves submeshes [FIX] ProBuilder : bevel_edges reports coplanar no-ops (report B3) - Returns changed (vertex/face-count delta) + a note when the bevel produces no geometry change [FIX] ProBuilder : MeshCollider re-synced after every rebuild (report B5) - Rebuild null-then-sets the MeshCollider's sharedMesh so physics matches the mesh after combine/boolean/any edit [FEAT] ProBuilder : create_shape layer / addCollider / parent (report B8) - Applied at creation with layerWarning/parentWarning on an unknown value; response echoes layer + hasCollider - parent falls back to a unique inactive-object match for a bare name --- CHANGELOG.md | 12 +++ Editor/MCPGameObjectCommands.cs | 51 +++++++++ Editor/MCPPrefabCommands.cs | 36 ++++++- Editor/MCPProBuilderCommands.cs | 185 +++++++++++++++++++++++++++++--- package.json | 2 +- 5 files changed, 270 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c38ee..8512417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this package will be documented in this file. +## [2.39.3] - 2026-07-23 + +### Fixed (Discord ProBuilder report — "shared-mesh hazards + 8 tool traps") +- **A1 — `unity_gameobject_duplicate` is now ProBuilder-safe.** `Object.Instantiate` of a ProBuilder object made the clone's MeshFilter share the source's *runtime* mesh; deleting either object later ran `ProBuilderMesh.OnDestroy`, which destroys that mesh — blanking every copy at once (the report's "delete 3 chairs → 21 remaining chairs invisible"). Each cloned `ProBuilderMesh` is now given its own independent mesh via ProBuilder's `MakeUnique()`, so the duplicate is a fully independent, still-editable object. Live-verified: source and clone no longer share a mesh, and destroying the source leaves the clone's mesh intact (24 verts). Response reports `proBuilderMeshesIsolated`. +- **A1 — `unity_gameobject_delete` guards the shared-mesh hazard.** Deleting an object whose runtime (non-asset) mesh is still referenced by MeshFilters outside its subtree (clones) is now refused with `requiresForce`/`sharedWith` instead of silently blanking the others; pass `force:true` to override. The external-sharer scan **includes inactive objects** (`FindObjectsInactive.Include`) — a toggled-off sibling was the one blind spot that would otherwise let the exact hazard through silently. Only runtime meshes are scanned, and only when the target actually has one, so normal asset-mesh deletes stay on the fast path. Live-verified with both active and inactive siblings; `force:true` deletes. +- **B2 — material lookup is consistent and never fails silently.** `create_shape`'s `material` accepted a bare name but, when it didn't resolve, fell back to the default with `success:true` and no signal; `set_face_material` demanded a full asset path. Both now share one resolver that accepts a full path OR a bare name (exact AssetDatabase match). `create_shape` surfaces `materialWarning` + echoes `appliedMaterial` when a requested material can't be found, so the fallback is never silent. A bare name that matches **more than one** material (common with asset packs / generic names) is resolved deterministically and disclosed — `materialWarning` names the ambiguity and `appliedMaterialPath` echoes exactly which asset was applied — rather than silently picking an arbitrary one. This also resolves **B1** (combine "losing" per-piece materials): with real materials actually applied, `CombineMeshes.Combine` preserves the submeshes — the previous single-submesh result came from every piece silently sharing the default (live-reproduced: two cubes with distinct materials combine to 2 submeshes `[MAT_A, MAT_B]`). +- **B3 — `bevel_edges` reports no-ops.** Bevel silently does nothing on some configurations (coplanar/interior faces left by a CSG cut); the response now includes `changed` (vertex/face-count delta) and a `note` when nothing moved. +- **B5 — MeshCollider stays in sync after every rebuild.** A `MeshCollider` on a ProBuilder object kept stale cooked collision after combine/boolean/any edit; the shared rebuild path now re-points it (null-then-set forces a re-cook). Live-verified after an extrude (24→40 verts, collider matches). +- **B8 — `create_shape` takes `layer`, `addCollider`, and `parent`.** Created objects landed on the Default layer, with no collider, at the scene root — forcing a follow-up call each time. All three are now applied at creation (layer by name or index; a MeshCollider matching the mesh; reparent by path), with `layerWarning`/`parentWarning` on an unknown layer/path. The response echoes `layer` and `hasCollider`. + +Inherent ProBuilder behavior, surfaced rather than "fixed": **B6** (a `set_face_material` submesh on a shared mesh doesn't sync sibling renderers — now mitigated by the A1 independent-mesh duplicate) and **B7** (structural ops renumber faces — re-enumerate before the next op). **C** (the five 2026-07-22 battle-test bugs) re-verified still fixed. + ## [2.39.2] - 2026-07-23 ### Security / Fixed (pre-merge multi-expert audit) diff --git a/Editor/MCPGameObjectCommands.cs b/Editor/MCPGameObjectCommands.cs index 8d12f1d..ae34991 100755 --- a/Editor/MCPGameObjectCommands.cs +++ b/Editor/MCPGameObjectCommands.cs @@ -58,11 +58,62 @@ public static object Delete(Dictionary args) var go = FindGameObject(args); if (go == null) return new { error = "GameObject not found" }; + // Shared runtime-mesh guard: a ProBuilderMesh owns a runtime mesh; if the object was + // cloned with duplicate/Object.Instantiate, its copies' MeshFilters point at that SAME + // mesh. Destroying this object runs ProBuilderMesh.OnDestroy, which destroys the mesh — + // and every copy goes invisible at once. Refuse unless force:true, and say how many + // objects would be hit. Only runtime (non-asset) meshes are at risk, so deleting + // normal asset-mesh objects stays on the fast path (report A1). + bool force = args.ContainsKey("force") && Convert.ToBoolean(args["force"]); + if (!force) + { + int sharedWith = CountExternalSharersOfRuntimeMesh(go); + if (sharedWith > 0) + return new + { + error = $"Refused: this object's runtime mesh is shared by {sharedWith} other object(s) (e.g. ProBuilder clones made with duplicate/Instantiate). Deleting it would blank their mesh too. Give each copy an independent mesh first (duplicate now does this automatically), or pass force:true to delete anyway.", + requiresForce = true, + sharedWith, + }; + } + string name = go.name; Undo.DestroyObjectImmediate(go); return new { success = true, deleted = name }; } + /// + /// Count MeshFilters OUTSIDE the given object's subtree that reference a runtime (non-asset) + /// mesh used inside the subtree. Non-zero means deleting the object would destroy a mesh + /// still in use elsewhere (the ProBuilder shared-mesh hazard). Fast-paths to 0 when the + /// subtree has no runtime meshes (the common case), so normal deletes pay no scan. + /// + private static int CountExternalSharersOfRuntimeMesh(GameObject go) + { + var runtimeMeshes = new HashSet(); + foreach (var mf in go.GetComponentsInChildren(true)) + if (mf.sharedMesh != null && !AssetDatabase.Contains(mf.sharedMesh)) + runtimeMeshes.Add(mf.sharedMesh); + if (runtimeMeshes.Count == 0) return 0; + + var inSubtree = new HashSet(); + foreach (var tr in go.GetComponentsInChildren(true)) + inSubtree.Add(tr); + + int count = 0; + // Include INACTIVE objects: FindObjectsByType's default excludes them, which would let + // an inactive sibling that shares the runtime mesh slip past the guard — the delete + // would then blank it silently (the exact hazard this guards). Matches the explicit + // opt-in used by FindGameObject above. + foreach (var mf in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) + { + if (mf.sharedMesh == null) continue; + if (inSubtree.Contains(mf.transform)) continue; + if (runtimeMeshes.Contains(mf.sharedMesh)) count++; + } + return count; + } + public static object GetInfo(Dictionary args) { var go = FindGameObject(args); diff --git a/Editor/MCPPrefabCommands.cs b/Editor/MCPPrefabCommands.cs index a7eda85..696e423 100644 --- a/Editor/MCPPrefabCommands.cs +++ b/Editor/MCPPrefabCommands.cs @@ -324,15 +324,49 @@ public static object Duplicate(Dictionary args) if (go.transform.parent != null) duplicate.transform.SetParent(go.transform.parent); + // ProBuilder-safe clone: Object.Instantiate makes the copy's MeshFilter share the SAME + // runtime mesh the source's ProBuilderMesh owns. Deleting either object later + // (ProBuilderMesh.OnDestroy destroys that mesh) would blank ALL the copies. Give each + // cloned ProBuilderMesh its own independent mesh via ProBuilder's MakeUnique() so the + // duplicate is a fully independent, still-editable object (report A1). + int pbIsolated = MakeProBuilderClonesIndependent(duplicate); + Undo.RegisterCreatedObjectUndo(duplicate, $"Duplicate {go.name}"); - return new Dictionary + var result = new Dictionary { { "success", true }, { "original", go.name }, { "duplicate", duplicate.name }, { "instanceId", MCPObjectId.Get(duplicate) }, }; + if (pbIsolated > 0) result["proBuilderMeshesIsolated"] = pbIsolated; + return result; + } + + /// + /// After an Object.Instantiate of a GameObject tree, give every cloned ProBuilderMesh an + /// independent runtime mesh (ProBuilder's MakeUnique) so the clone no longer shares the + /// source's mesh. Without this the copies share one mesh, and destroying any one of them + /// (or the source) blanks the rest — the core of the shared-mesh hazard (report A1). + /// Returns the count of ProBuilder meshes isolated; a harmless 0 when ProBuilder is absent. + /// + private static int MakeProBuilderClonesIndependent(GameObject clone) + { +#if PROBUILDER_INSTALLED + int count = 0; + foreach (var pb in clone.GetComponentsInChildren(true)) + { + pb.MakeUnique(); + pb.ToMesh(); + pb.Refresh(); + UnityEditor.ProBuilder.EditorUtility.SynchronizeWithMeshFilter(pb); + count++; + } + return count; +#else + return 0; +#endif } /// diff --git a/Editor/MCPProBuilderCommands.cs b/Editor/MCPProBuilderCommands.cs index 8ae86ba..23475e3 100644 --- a/Editor/MCPProBuilderCommands.cs +++ b/Editor/MCPProBuilderCommands.cs @@ -84,24 +84,63 @@ public static object CreateShape(Dictionary args) go.transform.position = new Vector3(GetFloat(p, "x", 0), GetFloat(p, "y", 0), GetFloat(p, "z", 0)); // ShapeGenerator leaves the renderer's material NULL — the shape renders magenta - // and a null material key crashes ProBuilder's CSG. Always end with a real - // material: the explicit arg when given, ProBuilder's default otherwise. - var mat = args.ContainsKey("material") && args["material"] != null - ? AssetDatabase.LoadAssetAtPath(args["material"].ToString()) - : null; + // and a null material key crashes ProBuilder's CSG. Always end with a real material: + // the requested one when given, ProBuilder's default otherwise. A requested-but- + // unresolved material used to fall back to default SILENTLY (the response still said + // success) — now it's surfaced as materialWarning so the caller never has to + // re-inspect the renderer to discover the fallback (report B2). + string matWarning = null; + string matPath = null; + Material mat = null; + if (args.ContainsKey("material") && args["material"] != null) + { + var spec = args["material"].ToString(); + mat = ResolveMaterial(spec, out matPath, out var matAmbiguity); + if (mat == null) + matWarning = $"Material '{spec}' not found (searched by asset path and by name) — applied ProBuilder's default instead."; + else + matWarning = matAmbiguity; // null unless a bare name matched >1 asset + } pb.SetMaterial(pb.faces, mat != null ? mat : BuiltinMaterials.defaultMaterial); Rebuild(pb); + + // Project-convention params applied at creation so an object doesn't need a follow-up + // call for each (a common source of serial omissions, report B8): layer (name or + // index), a MeshCollider, and a hierarchy parent. Set before RegisterCreatedObjectUndo + // so the whole configured object is captured as one undoable creation. + string layerWarning = ApplyLayer(go, args); + bool addedCollider = false; + if (GetBool(args, "addCollider", false)) + { + // Unity fake-null: '??' would keep a dead wrapper instead of adding the collider, + // so check with the overridden '== null' (csharp-unity skill §4). + var col = go.GetComponent(); + if (col == null) col = go.AddComponent(); + col.sharedMesh = go.GetComponent().sharedMesh; + addedCollider = true; + } + string parentWarning = ApplyParent(go, args); + Undo.RegisterCreatedObjectUndo(go, "Create ProBuilder " + shape); - // Echo the ACTUALLY-applied dimensions/position so a dropped or misparsed - // param is visible in the response instead of silently defaulting (battle-test rule). - return Ok(pb, new Dictionary + // Echo the ACTUALLY-applied dimensions/position/material/layer so a dropped or + // misparsed param is visible in the response instead of silently defaulting + // (battle-test rule). + var data = new Dictionary { { "shape", shape }, { "name", go.name }, { "appliedSize", V(size) }, { "appliedPosition", V(go.transform.position) }, - }); + { "layer", LayerMask.LayerToName(go.layer) }, + { "hasCollider", addedCollider }, + }; + if (mat != null) data["appliedMaterial"] = mat.name; + if (matPath != null) data["appliedMaterialPath"] = matPath; + if (matWarning != null) data["materialWarning"] = matWarning; + if (layerWarning != null) data["layerWarning"] = layerWarning; + if (parentWarning != null) data["parentWarning"] = parentWarning; + return Ok(pb, data); } // ───────────────────────────────────────────── @@ -172,9 +211,17 @@ public static object BevelEdges(Dictionary args) var edges = faces.SelectMany(f => f.edges).Distinct().ToList(); UndoRecord(pb, "Bevel Edges"); + int vBefore = pb.vertexCount, fBefore = pb.faceCount; Bevel.BevelEdges(pb, edges, amount); Rebuild(pb); - return Ok(pb, new Dictionary { { "beveledEdges", edges.Count }, { "amount", amount } }); + // Bevel silently no-ops on some configurations (e.g. coplanar interior faces left by a + // CSG cut): the call succeeds but geometry is unchanged. Surface that explicitly so the + // caller isn't left believing a bevel landed when nothing moved (report B3). + bool changed = pb.vertexCount != vBefore || pb.faceCount != fBefore; + var bevelData = new Dictionary { { "beveledEdges", edges.Count }, { "amount", amount }, { "changed", changed } }; + if (!changed) + bevelData["note"] = "Bevel produced no geometry change (edges may be coplanar/interior, or amount too small). Face indices are unchanged."; + return Ok(pb, bevelData); } public static object Subdivide(Dictionary args) @@ -234,16 +281,22 @@ public static object FlipNormals(Dictionary args) public static object SetFaceMaterial(Dictionary args) { if (!TryResolve(args, out var pb, out var err)) return err; - if (!args.ContainsKey("material") || args["material"] == null) return Error("material (asset path) is required."); - var mat = AssetDatabase.LoadAssetAtPath(args["material"].ToString()); - if (mat == null) return Error($"Material not found: {args["material"]}"); + if (!args.ContainsKey("material") || args["material"] == null) return Error("material (asset path or name) is required."); + // Same resolution as create_shape: full asset path OR a bare material name. These two + // tools used to diverge (create_shape took a name, set_face_material demanded a path) — + // report B2. They now accept identical forms. + var mat = ResolveMaterial(args["material"].ToString(), out var matPath, out var matAmbiguity); + if (mat == null) return Error($"Material not found (searched by asset path and by name): {args["material"]}"); var faces = args.ContainsKey("faceIndices") ? ResolveFaces(pb, args, out var fe) : pb.faces.ToList(); if (faces == null) return Error("Invalid faceIndices."); UndoRecord(pb, "Set Face Material"); pb.SetMaterial(faces, mat); Rebuild(pb); - return Ok(pb, new Dictionary { { "material", mat.name }, { "faces", faces.Count } }); + var smData = new Dictionary { { "material", mat.name }, { "faces", faces.Count } }; + if (matPath != null) smData["materialPath"] = matPath; + if (matAmbiguity != null) smData["materialWarning"] = matAmbiguity; + return Ok(pb, smData); } // ───────────────────────────────────────────── @@ -410,6 +463,17 @@ private static void Rebuild(ProBuilderMesh pb) pb.ToMesh(); pb.Refresh(); UnityEditor.ProBuilder.EditorUtility.SynchronizeWithMeshFilter(pb); + // A rebuild repopulates the MeshFilter's mesh; a MeshCollider on the same object keeps + // its stale cooked collision (or points at the old mesh) after combine/boolean/any + // edit. Re-point it (null then set forces a re-cook) so physics matches the visible + // geometry (report B5). Cheap null-check when there's no collider. + var col = pb.GetComponent(); + if (col != null) + { + var mf = pb.GetComponent(); + col.sharedMesh = null; + col.sharedMesh = mf != null ? mf.sharedMesh : null; + } } private static void UndoRecord(ProBuilderMesh pb, string msg) @@ -419,6 +483,99 @@ private static void UndoRecord(ProBuilderMesh pb, string msg) Undo.RegisterCompleteObjectUndo(pb, msg); } + /// + /// Resolve a material spec that may be a full asset path OR a bare name (e.g. + /// "MAT_Chair_Red"). The path is tried first; a bare name is looked up through the + /// AssetDatabase with an exact-leaf-name match. This makes create_shape and + /// set_face_material accept the SAME forms (report B2: they used to diverge — + /// create_shape silently defaulted on a bare name, set_face_material hard-required a + /// path). Returns null when nothing matches; 'resolvedPath' is the asset path loaded; + /// 'ambiguityWarning' is non-null when a bare name matched more than one asset (a + /// deterministic, disclosed choice is made rather than a silent arbitrary one). + /// + private static Material ResolveMaterial(string spec, out string resolvedPath, out string ambiguityWarning) + { + resolvedPath = null; + ambiguityWarning = null; + if (string.IsNullOrEmpty(spec)) return null; + // 1) Direct asset path (the historically-required, unambiguous form). + var direct = AssetDatabase.LoadAssetAtPath(spec); + if (direct != null) { resolvedPath = spec; return direct; } + // 2) Bare name (or path without extension): collect ALL exact leaf-name matches. + string leaf = System.IO.Path.GetFileNameWithoutExtension(spec); + if (string.IsNullOrEmpty(leaf)) return null; + var matches = new List(); + foreach (var guid in AssetDatabase.FindAssets("t:Material " + leaf)) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + if (string.Equals(System.IO.Path.GetFileNameWithoutExtension(path), leaf, StringComparison.Ordinal)) + matches.Add(path); + } + if (matches.Count == 0) return null; + // Same-named materials in different folders are common (asset packs, generic names). + // FindAssets gives no stable order, so pick deterministically (sorted) AND disclose the + // ambiguity instead of silently applying whichever came first — the caller can pass a + // full path to disambiguate. resolvedPath is always echoed so the choice is visible. + matches.Sort(StringComparer.Ordinal); + resolvedPath = matches[0]; + if (matches.Count > 1) + ambiguityWarning = $"Material name '{leaf}' is ambiguous ({matches.Count} matches) — used '{matches[0]}'. Pass a full asset path to select a specific one."; + return AssetDatabase.LoadAssetAtPath(resolvedPath); + } + + /// + /// Apply the optional 'layer' arg (a layer NAME or an int index). Returns a warning + /// string when the requested layer doesn't exist (rather than silently leaving the + /// object on its current layer), else null (report B8). + /// + private static string ApplyLayer(GameObject go, Dictionary args) + { + if (!args.ContainsKey("layer") || args["layer"] == null) return null; + var raw = args["layer"]; + int idx; + if (raw is long l) idx = (int)l; + else if (raw is int i) idx = i; + else if (raw is double db) idx = (int)db; + else + { + var name = raw.ToString(); + idx = int.TryParse(name, out var parsed) ? parsed : LayerMask.NameToLayer(name); + } + if (idx < 0 || idx > 31) + return $"Layer '{raw}' not found — left the object on '{LayerMask.LayerToName(go.layer)}'."; + go.layer = idx; + return null; + } + + /// + /// Reparent to the optional 'parent' arg (a hierarchy path), keeping world position. + /// Returns a warning when the parent path can't be found, else null (report B8). + /// + private static string ApplyParent(GameObject go, Dictionary args) + { + if (!args.ContainsKey("parent") || args["parent"] == null) return null; + var path = args["parent"].ToString(); + if (string.IsNullOrEmpty(path)) return null; + // GameObject.Find matches an ACTIVE object by hierarchy path — try that first. + var parent = GameObject.Find(path); + // Fallback for a bare NAME (no '/'): GameObject.Find can't see inactive objects (same + // Unity gotcha as the delete guard), so a toggled-off container ("Furniture") would be + // missed. Match it across inactive objects too, but only accept a UNIQUE match so we + // never silently grab the wrong container. + if (parent == null && !path.Contains("/")) + { + foreach (var g in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) + { + if (g.name != path) continue; + if (parent != null) return $"Parent '{path}' matches multiple inactive objects — left the object at the scene root; pass a full hierarchy path."; + parent = g; + } + } + if (parent == null) return $"Parent '{path}' not found — left the object at the scene root."; + go.transform.SetParent(parent.transform, true); + return null; + } + /// /// Replace null material slots with ProBuilder's default material. CSG keys a /// dictionary by material and throws ("Value cannot be null. Parameter name: key") diff --git a/package.json b/package.json index d921de6..dba5d45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.anklebreaker.unity-mcp", - "version": "2.39.2", + "version": "2.39.3", "displayName": "AnkleBreaker Unity MCP", "description": "MCP (Model Context Protocol) bridge plugin for Unity Editor. Enables AI assistants like Claude to control Unity Editor via a local HTTP API — manage scenes, GameObjects, components, assets, builds, and more.", "unity": "2021.3",