diff --git a/API_REFERENCE.MD b/API_REFERENCE.MD index c732570..2e2df54 100644 --- a/API_REFERENCE.MD +++ b/API_REFERENCE.MD @@ -209,6 +209,9 @@ This is the public MCP bridge macro for code edits. Pass `confirm: true` when wr Raw `attach_script`, `write_file`, and `write_files_batch` calls also require `confirm: true` before writing `.cs` files. +### `delete_asset` +Deletes a file or folder in the project. Requires `path` and `confirm: true`. Deletions use `AssetDatabase.MoveAssetToTrash` to send items to the OS Trash. Deletion of `ProjectSettings/` or `Packages/` paths is forbidden. + ### `unity_invoke_method` Invokes a C# method on a component through reflection. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6feb983..3b5386e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable public changes to Nexus Unity are documented here. ## [Unreleased] ### Security +- Require explicit confirmation (`confirm: true`) for `delete_asset`, enforce `AssetDatabase.MoveAssetToTrash` for OS trash recovery, and block creation, modification, moving, or deletion of `ProjectSettings/` and `Packages/` paths across all asset tools (#140). - Hardened external PR replay workflow (`approve-external-pr.yml`) with an in-workflow actor authorization gate and a PR content security pre-scan to prevent unauthorized execution and flag critical CI modifications on self-hosted runners (#144). - The content pre-scan now blocks the replay when a PR touches `.github/workflows/*`, `.githooks/*`, or `scripts/*`, requiring the maintainer to re-run with `acknowledge_critical_files: true` after reviewing the diff, instead of only logging a warning (#144). diff --git a/Editor/MCPServerMethods.Asset.cs b/Editor/MCPServerMethods.Asset.cs index a5cf9e1..5212726 100644 --- a/Editor/MCPServerMethods.Asset.cs +++ b/Editor/MCPServerMethods.Asset.cs @@ -54,25 +54,11 @@ private static JToken ExploreAsset(JToken p) foreach (var asset in allAssets) { if (asset == null) continue; - if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out string guid, out long fileId)) { - var assetData = new JObject - { - ["name"] = asset.name, - ["type"] = asset.GetType().Name, - ["file_id"] = fileId, - ["instance_id"] = asset.GetRawId() - }; - - if (asset == mainAsset) - { - result["main_asset"] = assetData; - } - else - { - subAssetsArr.Add(assetData); - } + var assetData = new JObject { ["name"] = asset.name, ["type"] = asset.GetType().Name, ["file_id"] = fileId, ["instance_id"] = asset.GetRawId() }; + if (asset == mainAsset) result["main_asset"] = assetData; + else subAssetsArr.Add(assetData); } } @@ -101,15 +87,10 @@ private static JToken CreateMaterial(JToken p) ApplyOptionalMaterialColor(mat, p["emission_color"] ?? (p["emission"]?.Type == JTokenType.Boolean ? null : p["emission"]), true); string path = p["path"]?.ToString(); - if (string.IsNullOrEmpty(path)) - { - path = Path.Combine("Assets", $"{name}.mat"); - } - else if (!path.EndsWith(".mat", StringComparison.OrdinalIgnoreCase)) - { - path += ".mat"; - } - path = ValidateAssetPath(path); + if (string.IsNullOrEmpty(path)) path = Path.Combine("Assets", $"{name}.mat"); + else if (!path.EndsWith(".mat", StringComparison.OrdinalIgnoreCase)) path += ".mat"; + + path = ValidateWritableAssetPath(path); string fullPath = ValidatePath(path); string directory = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) @@ -199,24 +180,16 @@ private static JToken RefreshAssetDatabase(JToken p) private static JToken ImportAsset(JToken p) { if (p == null || p["path"] == null) throw new Exception("path is required"); - string path = ValidateAssetPath(p["path"].ToString()); + string path = ValidateWritableAssetPath(p["path"].ToString()); AssetDatabase.ImportAsset(path); return new JObject { ["status"] = "Success", ["message"] = "Imported" }; } - - - - - - // Helper for isolated prefab asset edits - private static JToken MoveAsset(JToken p) { if (p?["old_path"] == null || p["new_path"] == null) throw new Exception("old_path and new_path required"); - - string oldPath = ValidateAssetPath(p["old_path"].ToString()); - string newPath = ValidateAssetPath(p["new_path"].ToString()); + string oldPath = ValidateWritableAssetPath(p["old_path"].ToString()); + string newPath = ValidateWritableAssetPath(p["new_path"].ToString(), allowAssetsRoot: true); if (AssetDatabase.IsValidFolder(oldPath) && AssetDatabase.IsValidFolder(newPath)) { @@ -224,6 +197,12 @@ private static JToken MoveAsset(JToken p) return new JObject { ["status"] = "Success", ["message"] = "OK (Merged)" }; } + if (newPath.Equals("Assets", StringComparison.OrdinalIgnoreCase)) + { + string fileName = Path.GetFileName(oldPath); + newPath = Path.Combine("Assets", fileName).Replace("\\", "/"); + } + string result = AssetDatabase.MoveAsset(oldPath, newPath); if (!string.IsNullOrEmpty(result)) throw new Exception(result); return new JObject { ["status"] = "Success", ["message"] = "OK" }; @@ -235,10 +214,8 @@ private static void MergeDirectories(string sourceDir, string targetDir) foreach (var file in files) { if (file.EndsWith(".meta")) continue; - string fileName = Path.GetFileName(file); string destFile = Path.Combine(targetDir, fileName).Replace("\\", "/"); - string result = AssetDatabase.MoveAsset(file.Replace("\\", "/"), destFile); if (!string.IsNullOrEmpty(result)) throw new Exception($"Failed to move {fileName}: {result}"); } @@ -248,12 +225,7 @@ private static void MergeDirectories(string sourceDir, string targetDir) { string dirName = Path.GetFileName(dir); string destDir = Path.Combine(targetDir, dirName).Replace("\\", "/"); - - if (!AssetDatabase.IsValidFolder(destDir)) - { - AssetDatabase.CreateFolder(targetDir, dirName); - } - + if (!AssetDatabase.IsValidFolder(destDir)) AssetDatabase.CreateFolder(targetDir, dirName); MergeDirectories(dir.Replace("\\", "/"), destDir); } @@ -263,8 +235,22 @@ private static void MergeDirectories(string sourceDir, string targetDir) private static JToken DeleteAsset(JToken p) { if (p?["path"] == null) throw new Exception("path required"); - string path = ValidateAssetPath(p["path"].ToString()); - if (!AssetDatabase.DeleteAsset(path)) throw new Exception("Delete failed"); + if (p?["confirm"]?.Value() != true) throw new ArgumentException("Deleting an asset requires confirm: true because it is destructive."); + + string rawPath = p["path"]?.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(rawPath)) throw new Exception("Asset path cannot be empty"); + if (rawPath.Contains("\0")) throw new Exception("Invalid character in asset path"); + + string path = ValidateWritableAssetPath(rawPath, allowAssetsRoot: false).TrimEnd('/', '\\'); + if (path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) throw new Exception("Cannot delete .meta files directly. Delete the main asset file or folder instead."); + + string fullPath = ValidatePath(path); + if (!File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new Exception($"Asset not found: {path}"); + + bool movedToTrash = AssetDatabase.MoveAssetToTrash(path); + if (!movedToTrash) throw new Exception($"Failed to move asset to OS trash at path: '{path}'. Permanent deletion was blocked for security."); + + AssetDatabase.Refresh(); return new JObject { ["status"] = "Success", ["message"] = "OK" }; } @@ -272,7 +258,14 @@ private static JToken CopyAsset(JToken p) { if (p?["source_path"] == null || p["dest_path"] == null) throw new Exception("source_path and dest_path required"); string sourcePath = ValidateAssetPath(p["source_path"].ToString()); - string destPath = ValidateAssetPath(p["dest_path"].ToString()); + string destPath = ValidateWritableAssetPath(p["dest_path"].ToString(), allowAssetsRoot: true); + + if (destPath.Equals("Assets", StringComparison.OrdinalIgnoreCase)) + { + string fileName = Path.GetFileName(sourcePath); + destPath = Path.Combine("Assets", fileName).Replace("\\", "/"); + } + if (!AssetDatabase.CopyAsset(sourcePath, destPath)) throw new Exception("Copy failed"); return new JObject { ["status"] = "Success", ["message"] = "OK" }; } @@ -289,7 +282,7 @@ private static JToken GetDependencies(JToken p) private static JToken CreateFolder(JToken p) { if (p?["path"] == null) throw new Exception("path required (e.g., 'Assets/NewFolder')"); - string path = ValidateAssetPath(p["path"].ToString()); + string path = ValidateWritableAssetPath(p["path"].ToString()); string parent = Path.GetDirectoryName(path).Replace("\\", "/"); string name = Path.GetFileName(path); string guid = AssetDatabase.CreateFolder(parent, name); @@ -297,4 +290,4 @@ private static JToken CreateFolder(JToken p) return new JObject { ["status"] = "Success" }; } } -}// Force Wed Apr 1 21:53:54 CEST 2026 +} diff --git a/Editor/MCPServerMethods.SerializationUtils.cs b/Editor/MCPServerMethods.SerializationUtils.cs new file mode 100644 index 0000000..3eacdbf --- /dev/null +++ b/Editor/MCPServerMethods.SerializationUtils.cs @@ -0,0 +1,69 @@ +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace UnityMCP.Editor +{ + public static partial class MCPServerMethods + { + internal static JObject SerializeVector3(Vector3 v) + { + return new JObject { ["x"] = v.x, ["y"] = v.y, ["z"] = v.z }; + } + + internal static string SerializeColor(Color c) + { + return "#" + ColorUtility.ToHtmlStringRGBA(c); + } + + private static EditorWindow FindWindow(string title) + { + return Resources.FindObjectsOfTypeAll().FirstOrDefault(w => w.titleContent.text == title); + } + + private static VisualElement FindElementByName(VisualElement root, string name) + { + if (root == null) return null; + if (root.name == name) return root; + return root.Q(name); + } + + private static JToken SerializeVisualElement(VisualElement el, bool deep = false) + { + var obj = new JObject + { + ["name"] = el.name, + ["type"] = el.GetType().Name, + ["visible"] = el.resolvedStyle.display != DisplayStyle.None + }; + + if (el is TextElement te && !string.IsNullOrEmpty(te.text)) + obj["text"] = te.text; + + var classes = el.GetClasses().ToList(); + if (classes.Count > 0) + obj["classes"] = new JArray(classes); + + if (deep) + { + var rect = el.layout; + obj["layout"] = new JObject { ["x"] = rect.x, ["y"] = rect.y, ["width"] = rect.width, ["height"] = rect.height }; + + var style = new JObject(); + style["display"] = el.resolvedStyle.display.ToString(); + style["visibility"] = el.resolvedStyle.visibility.ToString(); + style["opacity"] = el.resolvedStyle.opacity; + style["color"] = el.resolvedStyle.color.ToString(); + style["backgroundColor"] = el.resolvedStyle.backgroundColor.ToString(); + obj["computed_style"] = style; + } + + var children = new JArray(); + foreach (var child in el.Children()) children.Add(SerializeVisualElement(child, deep)); + if (children.Count > 0) obj["children"] = children; + return obj; + } + } +} diff --git a/Editor/MCPServerMethods.SerializationUtils.cs.meta b/Editor/MCPServerMethods.SerializationUtils.cs.meta new file mode 100644 index 0000000..ec773e0 --- /dev/null +++ b/Editor/MCPServerMethods.SerializationUtils.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MCPServerMethods.Tools.cs b/Editor/MCPServerMethods.Tools.cs index 6d84cf3..964af76 100644 --- a/Editor/MCPServerMethods.Tools.cs +++ b/Editor/MCPServerMethods.Tools.cs @@ -264,7 +264,7 @@ private static void AddAssetTools(JArray tools) tools.Add(CreateTool("get_prefab_overrides", "Get list of property and component modifications on a prefab instance", new JObject { ["instance_id"] = new JObject { ["type"] = "integer" } }, "instance_id")); tools.Add(CreateTool("edit_prefab_asset", "Directly modify a prefab asset on disk without instantiating it", new JObject { ["path"] = new JObject { ["type"] = "string" }, ["component_name"] = new JObject { ["type"] = "string", ["description"] = "Optional component name to target" }, ["properties"] = new JObject { ["type"] = "object" } }, "path", "properties")); tools.Add(CreateTool("move_asset", "Move/Rename", new JObject { ["old_path"] = new JObject { ["type"] = "string" }, ["new_path"] = new JObject { ["type"] = "string" } }, "old_path", "new_path")); - tools.Add(CreateTool("delete_asset", "Delete file", new JObject { ["path"] = new JObject { ["type"] = "string" } }, "path")); + tools.Add(CreateTool("delete_asset", "Delete file or folder (moves to OS trash)", new JObject { ["path"] = new JObject { ["type"] = "string" }, ["confirm"] = new JObject { ["type"] = "boolean", ["description"] = "Required: must be true to confirm asset deletion" } }, "path", "confirm")); tools.Add(CreateTool("copy_asset", "Duplicate file", new JObject { ["source_path"] = new JObject { ["type"] = "string" }, ["dest_path"] = new JObject { ["type"] = "string" } }, "source_path", "dest_path")); tools.Add(CreateTool("get_dependencies", "Get file deps", new JObject { ["path"] = new JObject { ["type"] = "string" } }, "path")); tools.Add(CreateTool("create_folder", "Create directory", new JObject { ["path"] = new JObject { ["type"] = "string" } }, "path")); @@ -273,96 +273,6 @@ private static void AddAssetTools(JArray tools) tools.Add(CreateTool("write_files_batch", "Write multiple files in a single pass", new JObject { ["files"] = new JObject { ["type"] = "array", ["items"] = new JObject { ["type"] = "object", ["properties"] = new JObject { ["path"] = new JObject { ["type"] = "string" }, ["content"] = new JObject { ["type"] = "string" } }, ["required"] = new JArray { "path", "content" } } }, ["confirm"] = new JObject { ["type"] = "boolean", ["description"] = "Required when any file path ends in .cs because Unity compilation is triggered" } }, "files")); } - private static void AddEditorControlTools(JArray tools) - { - tools.Add(CreateTool("undo", "Unity Undo", new JObject { })); - tools.Add(CreateTool("redo", "Unity Redo", new JObject { })); - tools.Add(CreateTool("toggle_play_mode", "Start/Stop", new JObject { ["value"] = new JObject { ["type"] = "boolean" } })); - tools.Add(CreateTool("pause_play_mode", "Pause/Unpause", new JObject { ["value"] = new JObject { ["type"] = "boolean" } })); - tools.Add(CreateTool("step_frame", "Advance frame", new JObject { })); - tools.Add(CreateTool("execute_menu_item", "Execute Menu", new JObject { ["item_path"] = new JObject { ["type"] = "string" } }, "item_path")); - tools.Add(CreateTool("focus_scene_view", "Frame selection", new JObject { })); - tools.Add(CreateTool("open_prefab_stage", "Open prefab asset in isolation mode", new JObject { ["path"] = new JObject { ["type"] = "string" } }, "path")); - tools.Add(CreateTool("close_prefab_stage", "Exit prefab isolation mode", new JObject { })); - tools.Add(CreateTool("read_logs", "Get Console logs with optional noise reduction", new JObject - { - ["count"] = new JObject { ["type"] = "integer", ["description"] = "Number of logs to retrieve" }, - ["structured"] = new JObject { ["type"] = "boolean", ["description"] = "If true, collapses consecutive identical messages" }, - ["filter_type"] = new JObject { ["type"] = "string", ["description"] = "Filter by type (Log, Warning, Error)" }, - ["search_text"] = new JObject { ["type"] = "string", ["description"] = "Search in message or stacktrace" } - })); - tools.Add(CreateTool("read_logs_since_cursor", "Read only new logs since last poll with optional noise reduction", new JObject - { - ["cursor"] = new JObject { ["type"] = "integer", ["description"] = "Last seen log ID" }, - ["structured"] = new JObject { ["type"] = "boolean", ["description"] = "If true, collapses consecutive identical messages" }, - ["severities"] = new JObject { ["type"] = "array", ["items"] = new JObject { ["type"] = "string" }, ["description"] = "e.g. ['Error', 'Exception']" }, - ["search_text"] = new JObject { ["type"] = "string", ["description"] = "Filter by content" } - })); - tools.Add(CreateTool("clear_logs", "Clear Console", new JObject { })); - tools.Add(CreateTool("attach_script", "Create & Link C#", new JObject { ["script_name"] = new JObject { ["type"] = "string" }, ["script_content"] = new JObject { ["type"] = "string" }, ["confirm"] = new JObject { ["type"] = "boolean", ["description"] = "Required because this writes a .cs file and triggers Unity compilation" } }, "script_name", "confirm")); - tools.Add(CreateTool("wait_for_ready", "Wait until server is responsive", new JObject { })); - tools.Add(CreateTool("run_tests", "Run NUnit tests in the editor", new JObject - { - ["filter"] = new JObject { ["type"] = "string", ["description"] = "Optional: name of test or class" }, - ["mode"] = new JObject { ["type"] = "string", ["description"] = "EditMode or PlayMode" } - })); - tools.Add(CreateTool("get_test_results", "Read the latest Unity TestResults XML summary", new JObject - { - ["result_path"] = new JObject { ["type"] = "string", ["description"] = "Optional TestResults XML path inside the project or Unity persistent data path" } - })); - } - - private static void AddDiscoveryTools(JArray tools) - { - tools.Add(CreateTool("get_game_object", "Get object data", new JObject { ["instance_id"] = new JObject { ["type"] = "integer" } }, "instance_id")); - tools.Add(CreateTool("get_active_game_object", "Get current selection", new JObject { })); - tools.Add(CreateTool("get_root_game_objects", "Get top-level objects", new JObject { })); - tools.Add(CreateTool("get_object_path", "Get hierarchy breadcrumb", new JObject { ["instance_id"] = new JObject { ["type"] = "integer" } }, "instance_id")); - tools.Add(CreateTool("find_objects", "Deep search", GetSearchSchema())); - tools.Add(CreateTool("find_by_path", "Search by exact path", new JObject { ["path"] = new JObject { ["type"] = "string" } }, "path")); - tools.Add(CreateTool("find_references", "Find scene and asset references to a target object or GUID", new JObject - { - ["target_id"] = new JObject { ["type"] = "integer", ["description"] = "Optional scene instance/entity id" }, - ["target_guid"] = new JObject { ["type"] = "string", ["description"] = "Optional asset GUID" } - })); - tools.Add(CreateTool("get_tags_and_layers", "Get Tags/Layers list", new JObject { })); - tools.Add(CreateTool("ping_object", "Ping in Editor", new JObject { ["instance_id"] = new JObject { ["type"] = "integer" } }, "instance_id")); - tools.Add(CreateTool("get_children", "Get direct children", new JObject { ["instance_id"] = new JObject { ["type"] = "integer" } }, "instance_id")); - tools.Add(CreateTool("get_editor_state", "Get Play/Paused/Compiling", new JObject { })); - tools.Add(CreateTool("get_project_info", "Get Project metadata", new JObject { })); - tools.Add(CreateTool("set_selection", "Select objects", new JObject { ["instance_ids"] = new JObject { ["type"] = "array", ["items"] = new JObject { ["type"] = "integer" } } }, "instance_ids")); - } - - private static void AddUITools(JArray tools) - { - tools.Add(CreateTool("ui_list_windows", "List Editor Windows", new JObject { })); - tools.Add(CreateTool("ui_get_hierarchy", "Inspect Window UI", new JObject { ["window_title"] = new JObject { ["type"] = "string" } }, "window_title")); - tools.Add(CreateTool("ui_get_window_rect", "Get an EditorWindow position and size for layout QA", new JObject { ["window_title"] = new JObject { ["type"] = "string" } }, "window_title")); - tools.Add(CreateTool("ui_set_window_rect", "Set an EditorWindow position and size for layout QA", new JObject - { - ["window_title"] = new JObject { ["type"] = "string" }, - ["x"] = new JObject { ["type"] = "number" }, - ["y"] = new JObject { ["type"] = "number" }, - ["width"] = new JObject { ["type"] = "number" }, - ["height"] = new JObject { ["type"] = "number" } - }, "window_title")); - tools.Add(CreateTool("ui_capture_window_snapshot", "Capture an EditorWindow rect, UI hierarchy, and optional PNG image", new JObject - { - ["window_title"] = new JObject { ["type"] = "string" }, - ["include_image"] = new JObject { ["type"] = "boolean" }, - ["include_hierarchy"] = new JObject { ["type"] = "boolean" } - }, "window_title")); - tools.Add(CreateTool("ui_query_elements", "Find UI Toolkit elements by text, name, or USS class", new JObject - { - ["window_title"] = new JObject { ["type"] = "string" }, - ["name"] = new JObject { ["type"] = "string" }, - ["text"] = new JObject { ["type"] = "string" }, - ["class_name"] = new JObject { ["type"] = "string" } - }, "window_title")); - tools.Add(CreateTool("ui_click", "Simulate UI Click", new JObject { ["window_title"] = new JObject { ["type"] = "string" }, ["element_name"] = new JObject { ["type"] = "string" } }, "window_title", "element_name")); - tools.Add(CreateTool("ui_input_text", "Type into UI field", new JObject { ["window_title"] = new JObject { ["type"] = "string" }, ["element_name"] = new JObject { ["type"] = "string" }, ["text"] = new JObject { ["type"] = "string" } }, "window_title", "element_name", "text")); - } - private static JObject CreateTool(string name, string desc, JObject props, params string[] required) { var tool = new JObject { ["name"] = name, ["description"] = desc }; @@ -371,79 +281,5 @@ private static JObject CreateTool(string name, string desc, JObject props, param tool["inputSchema"] = schema; return tool; } - - private static JObject GetPrimitiveSchema() - { - var schema = new JObject(); - var types = new JArray("Cube", "Sphere", "Capsule", "Cylinder", "Plane", "Quad"); - schema["primitive_type"] = new JObject { ["type"] = "string", ["enum"] = types }; - schema["name"] = new JObject { ["type"] = "string" }; - schema["parent_id"] = new JObject { ["type"] = "integer" }; - schema["position"] = GetVector3Schema(); - schema["rotation"] = GetVector3Schema(); - schema["scale"] = GetVector3Schema(); - schema["material_path"] = new JObject { ["type"] = "string" }; - return schema; - } - private static JObject GetSearchSchema() => new JObject { ["name"] = new JObject { ["type"] = "string" }, ["tag"] = new JObject { ["type"] = "string" }, ["type"] = new JObject { ["type"] = "string" } }; - private static JObject GetTransformSchema() => new JObject - { - ["instance_id"] = new JObject { ["type"] = "integer" }, - ["position"] = GetVector3Schema(), - ["rotation"] = GetVector3Schema(), - ["scale"] = GetVector3Schema(), - ["eulerAngles"] = GetVector3Schema(), - ["localScale"] = GetVector3Schema() - }; - - private static JObject GetVector3Schema() => new JObject - { - ["oneOf"] = new JArray( - new JObject - { - ["type"] = "object", - ["properties"] = new JObject - { - ["x"] = new JObject { ["type"] = "number" }, - ["y"] = new JObject { ["type"] = "number" }, - ["z"] = new JObject { ["type"] = "number" } - } - }, - new JObject - { - ["type"] = "array", - ["items"] = new JObject { ["type"] = "number" }, - ["minItems"] = 3, - ["maxItems"] = 3 - }) - }; - - private static string SanitizeScriptName(string n) => System.Text.RegularExpressions.Regex.Replace(n, @"[^a-zA-Z0-9_]", "_"); - private static string GetDefaultScript(string n) => $"using UnityEngine;\npublic class {n} : MonoBehaviour {{ void Start() {{ Debug.Log(\"Hello from {n}\"); }} }}"; - - private static List CollapseLogs(List logs) - { - if (logs == null || logs.Count == 0) return logs; - - var collapsed = new List(); - foreach (var log in logs) - { - int lastIdx = collapsed.Count - 1; - if (lastIdx >= 0) - { - var last = collapsed[lastIdx]; - if (last.Message == log.Message && last.Type == log.Type) - { - last.Count += log.Count; - // Keep the lexicographically later timestamp (most recent within same day) - if (string.Compare(last.Timestamp, log.Timestamp) < 0) - last.Timestamp = log.Timestamp; - continue; - } - } - collapsed.Add(new LogEntry(log)); - } - return collapsed; - } } } diff --git a/Editor/MCPServerMethods.ToolsExtended.cs b/Editor/MCPServerMethods.ToolsExtended.cs new file mode 100644 index 0000000..29655ac --- /dev/null +++ b/Editor/MCPServerMethods.ToolsExtended.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace UnityMCP.Editor +{ + public static partial class MCPServerMethods + { + private static void AddEditorControlTools(JArray tools) + { + tools.Add(CreateTool("undo", "Unity Undo", new JObject { })); + tools.Add(CreateTool("redo", "Unity Redo", new JObject { })); + tools.Add(CreateTool("toggle_play_mode", "Start/Stop", new JObject { ["value"] = new JObject { ["type"] = "boolean" } })); + tools.Add(CreateTool("pause_play_mode", "Pause/Unpause", new JObject { ["value"] = new JObject { ["type"] = "boolean" } })); + tools.Add(CreateTool("step_frame", "Advance frame", new JObject { })); + tools.Add(CreateTool("execute_menu_item", "Execute Menu", new JObject { ["item_path"] = new JObject { ["type"] = "string" } }, "item_path")); + tools.Add(CreateTool("focus_scene_view", "Frame selection", new JObject { })); + tools.Add(CreateTool("open_prefab_stage", "Open prefab asset in isolation mode", new JObject { ["path"] = new JObject { ["type"] = "string" } }, "path")); + tools.Add(CreateTool("close_prefab_stage", "Exit prefab isolation mode", new JObject { })); + tools.Add(CreateTool("read_logs", "Get Console logs with optional noise reduction", new JObject + { + ["count"] = new JObject { ["type"] = "integer", ["description"] = "Number of logs to retrieve" }, + ["structured"] = new JObject { ["type"] = "boolean", ["description"] = "If true, collapses consecutive identical messages" }, + ["filter_type"] = new JObject { ["type"] = "string", ["description"] = "Filter by type (Log, Warning, Error)" }, + ["search_text"] = new JObject { ["type"] = "string", ["description"] = "Search in message or stacktrace" } + })); + tools.Add(CreateTool("read_logs_since_cursor", "Read only new logs since last poll with optional noise reduction", new JObject + { + ["cursor"] = new JObject { ["type"] = "integer", ["description"] = "Last seen log ID" }, + ["structured"] = new JObject { ["type"] = "boolean", ["description"] = "If true, collapses consecutive identical messages" }, + ["severities"] = new JObject { ["type"] = "array", ["items"] = new JObject { ["type"] = "string" }, ["description"] = "e.g. ['Error', 'Exception']" }, + ["search_text"] = new JObject { ["type"] = "string", ["description"] = "Filter by content" } + })); + tools.Add(CreateTool("clear_logs", "Clear Console", new JObject { })); + tools.Add(CreateTool("attach_script", "Create & Link C#", new JObject { ["script_name"] = new JObject { ["type"] = "string" }, ["script_content"] = new JObject { ["type"] = "string" }, ["confirm"] = new JObject { ["type"] = "boolean", ["description"] = "Required because this writes a .cs file and triggers Unity compilation" } }, "script_name", "confirm")); + tools.Add(CreateTool("wait_for_ready", "Wait until server is responsive", new JObject { })); + tools.Add(CreateTool("run_tests", "Run NUnit tests in the editor", new JObject + { + ["filter"] = new JObject { ["type"] = "string", ["description"] = "Optional: name of test or class" }, + ["mode"] = new JObject { ["type"] = "string", ["description"] = "EditMode or PlayMode" } + })); + tools.Add(CreateTool("get_test_results", "Read the latest Unity TestResults XML summary", new JObject + { + ["result_path"] = new JObject { ["type"] = "string", ["description"] = "Optional TestResults XML path inside the project or Unity persistent data path" } + })); + } + + private static void AddDiscoveryTools(JArray tools) + { + tools.Add(CreateTool("get_game_object", "Get object data", new JObject { ["instance_id"] = new JObject { ["type"] = "integer" } }, "instance_id")); + tools.Add(CreateTool("get_active_game_object", "Get current selection", new JObject { })); + tools.Add(CreateTool("get_root_game_objects", "Get top-level objects", new JObject { })); + tools.Add(CreateTool("get_object_path", "Get hierarchy breadcrumb", new JObject { ["instance_id"] = new JObject { ["type"] = "integer" } }, "instance_id")); + tools.Add(CreateTool("find_objects", "Deep search", GetSearchSchema())); + tools.Add(CreateTool("find_by_path", "Search by exact path", new JObject { ["path"] = new JObject { ["type"] = "string" } }, "path")); + tools.Add(CreateTool("find_references", "Find scene and asset references to a target object or GUID", new JObject + { + ["target_id"] = new JObject { ["type"] = "integer", ["description"] = "Optional scene instance/entity id" }, + ["target_guid"] = new JObject { ["type"] = "string", ["description"] = "Optional asset GUID" } + })); + tools.Add(CreateTool("get_tags_and_layers", "Get Tags/Layers list", new JObject { })); + tools.Add(CreateTool("ping_object", "Ping in Editor", new JObject { ["instance_id"] = new JObject { ["type"] = "integer" } }, "instance_id")); + tools.Add(CreateTool("get_children", "Get direct children", new JObject { ["instance_id"] = new JObject { ["type"] = "integer" } }, "instance_id")); + tools.Add(CreateTool("get_editor_state", "Get Play/Paused/Compiling", new JObject { })); + tools.Add(CreateTool("get_project_info", "Get Project metadata", new JObject { })); + tools.Add(CreateTool("set_selection", "Select objects", new JObject { ["instance_ids"] = new JObject { ["type"] = "array", ["items"] = new JObject { ["type"] = "integer" } } }, "instance_ids")); + } + + private static void AddUITools(JArray tools) + { + tools.Add(CreateTool("ui_list_windows", "List Editor Windows", new JObject { })); + tools.Add(CreateTool("ui_get_hierarchy", "Inspect Window UI", new JObject { ["window_title"] = new JObject { ["type"] = "string" } }, "window_title")); + tools.Add(CreateTool("ui_get_window_rect", "Get an EditorWindow position and size for layout QA", new JObject { ["window_title"] = new JObject { ["type"] = "string" } }, "window_title")); + tools.Add(CreateTool("ui_set_window_rect", "Set an EditorWindow position and size for layout QA", new JObject + { + ["window_title"] = new JObject { ["type"] = "string" }, + ["x"] = new JObject { ["type"] = "number" }, + ["y"] = new JObject { ["type"] = "number" }, + ["width"] = new JObject { ["type"] = "number" }, + ["height"] = new JObject { ["type"] = "number" } + }, "window_title")); + tools.Add(CreateTool("ui_capture_window_snapshot", "Capture an EditorWindow rect, UI hierarchy, and optional PNG image", new JObject + { + ["window_title"] = new JObject { ["type"] = "string" }, + ["include_image"] = new JObject { ["type"] = "boolean" }, + ["include_hierarchy"] = new JObject { ["type"] = "boolean" } + }, "window_title")); + tools.Add(CreateTool("ui_query_elements", "Find UI Toolkit elements by text, name, or USS class", new JObject + { + ["window_title"] = new JObject { ["type"] = "string" }, + ["name"] = new JObject { ["type"] = "string" }, + ["text"] = new JObject { ["type"] = "string" }, + ["class_name"] = new JObject { ["type"] = "string" } + }, "window_title")); + tools.Add(CreateTool("ui_click", "Simulate UI Click", new JObject { ["window_title"] = new JObject { ["type"] = "string" }, ["element_name"] = new JObject { ["type"] = "string" } }, "window_title", "element_name")); + tools.Add(CreateTool("ui_input_text", "Type into UI field", new JObject { ["window_title"] = new JObject { ["type"] = "string" }, ["element_name"] = new JObject { ["type"] = "string" }, ["text"] = new JObject { ["type"] = "string" } }, "window_title", "element_name", "text")); + } + + private static JObject GetPrimitiveSchema() + { + var schema = new JObject(); + var types = new JArray("Cube", "Sphere", "Capsule", "Cylinder", "Plane", "Quad"); + schema["primitive_type"] = new JObject { ["type"] = "string", ["enum"] = types }; + schema["name"] = new JObject { ["type"] = "string" }; + schema["parent_id"] = new JObject { ["type"] = "integer" }; + schema["position"] = GetVector3Schema(); + schema["rotation"] = GetVector3Schema(); + schema["scale"] = GetVector3Schema(); + schema["material_path"] = new JObject { ["type"] = "string" }; + return schema; + } + + private static JObject GetSearchSchema() => new JObject { ["name"] = new JObject { ["type"] = "string" }, ["tag"] = new JObject { ["type"] = "string" }, ["type"] = new JObject { ["type"] = "string" } }; + + private static JObject GetTransformSchema() => new JObject + { + ["instance_id"] = new JObject { ["type"] = "integer" }, + ["position"] = GetVector3Schema(), + ["rotation"] = GetVector3Schema(), + ["scale"] = GetVector3Schema(), + ["eulerAngles"] = GetVector3Schema(), + ["localScale"] = GetVector3Schema() + }; + + private static JObject GetVector3Schema() => new JObject + { + ["oneOf"] = new JArray( + new JObject + { + ["type"] = "object", + ["properties"] = new JObject + { + ["x"] = new JObject { ["type"] = "number" }, + ["y"] = new JObject { ["type"] = "number" }, + ["z"] = new JObject { ["type"] = "number" } + } + }, + new JObject + { + ["type"] = "array", + ["items"] = new JObject { ["type"] = "number" }, + ["minItems"] = 3, + ["maxItems"] = 3 + }) + }; + + private static string SanitizeScriptName(string n) => System.Text.RegularExpressions.Regex.Replace(n, @"[^a-zA-Z0-9_]", "_"); + private static string GetDefaultScript(string n) => $"using UnityEngine;\npublic class {n} : MonoBehaviour {{ void Start() {{ Debug.Log(\"Hello from {n}\"); }} }}"; + + private static List CollapseLogs(List logs) + { + if (logs == null || logs.Count == 0) return logs; + + var collapsed = new List(); + foreach (var log in logs) + { + int lastIdx = collapsed.Count - 1; + if (lastIdx >= 0) + { + var last = collapsed[lastIdx]; + if (last.Message == log.Message && last.Type == log.Type) + { + last.Count += log.Count; + if (string.Compare(last.Timestamp, log.Timestamp) < 0) + last.Timestamp = log.Timestamp; + continue; + } + } + collapsed.Add(new LogEntry(log)); + } + return collapsed; + } + } +} diff --git a/Editor/MCPServerMethods.ToolsExtended.cs.meta b/Editor/MCPServerMethods.ToolsExtended.cs.meta new file mode 100644 index 0000000..020e948 --- /dev/null +++ b/Editor/MCPServerMethods.ToolsExtended.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MCPServerMethods.Utils.cs b/Editor/MCPServerMethods.Utils.cs index 273be7f..715c727 100644 --- a/Editor/MCPServerMethods.Utils.cs +++ b/Editor/MCPServerMethods.Utils.cs @@ -223,76 +223,50 @@ internal static string ValidateAssetPath(string path) return relativePath; } - private static bool IsCSharpScriptPath(string path) + /// + /// Validates that an asset path is safe for write/modification operations. + /// Restricts targets to the 'Assets' folder (or optionally 'Assets' root directory itself). + /// Rejects any attempt to modify system folders (Packages, ProjectSettings). + /// Returns the relative, normalized asset path. + /// + internal static string ValidateWritableAssetPath(string path, bool allowAssetsRoot = false) { - return path.EndsWith(".cs", System.StringComparison.OrdinalIgnoreCase); - } + string relativePath = ValidateAssetPath(path); + string clean = relativePath.TrimEnd('/', '\\'); - private static void RequireScriptWriteConfirmation(JToken p) - { - if (p?["confirm"]?.Value() != true) + if (clean.Equals("Packages", StringComparison.OrdinalIgnoreCase) || + clean.Equals("ProjectSettings", StringComparison.OrdinalIgnoreCase) || + clean.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase) || + clean.StartsWith("ProjectSettings/", StringComparison.OrdinalIgnoreCase)) { - throw new System.ArgumentException("Writing C# scripts requires confirm: true because it triggers Unity compilation."); + throw new System.Exception($"Modifying root or system folders (Packages, ProjectSettings) is forbidden. Path: '{path}'"); } - } - internal static JObject SerializeVector3(Vector3 v) - { - return new JObject { ["x"] = v.x, ["y"] = v.y, ["z"] = v.z }; - } + if (clean.Equals("Assets", StringComparison.OrdinalIgnoreCase)) + { + if (allowAssetsRoot) return relativePath; + throw new System.Exception("Cannot delete or overwrite the root 'Assets' folder directly."); + } - internal static string SerializeColor(Color c) - { - return "#" + ColorUtility.ToHtmlStringRGBA(c); - } + if (!clean.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + throw new System.Exception($"Modifying assets outside 'Assets/' is forbidden. Path: '{path}'"); + } - private static EditorWindow FindWindow(string title) - { - return Resources.FindObjectsOfTypeAll().FirstOrDefault(w => w.titleContent.text == title); + return relativePath; } - private static VisualElement FindElementByName(VisualElement root, string name) + private static bool IsCSharpScriptPath(string path) { - if (root == null) return null; - if (root.name == name) return root; - return root.Q(name); + return path.EndsWith(".cs", System.StringComparison.OrdinalIgnoreCase); } - private static JToken SerializeVisualElement(VisualElement el, bool deep = false) + private static void RequireScriptWriteConfirmation(JToken p) { - var obj = new JObject - { - ["name"] = el.name, - ["type"] = el.GetType().Name, - ["visible"] = el.resolvedStyle.display != DisplayStyle.None - }; - - if (el is TextElement te && !string.IsNullOrEmpty(te.text)) - obj["text"] = te.text; - - var classes = el.GetClasses().ToList(); - if (classes.Count > 0) - obj["classes"] = new JArray(classes); - - if (deep) + if (p?["confirm"]?.Value() != true) { - var rect = el.layout; - obj["layout"] = new JObject { ["x"] = rect.x, ["y"] = rect.y, ["width"] = rect.width, ["height"] = rect.height }; - - // Add useful computed styles - var style = new JObject(); - style["display"] = el.resolvedStyle.display.ToString(); - style["visibility"] = el.resolvedStyle.visibility.ToString(); - style["opacity"] = el.resolvedStyle.opacity; - style["color"] = el.resolvedStyle.color.ToString(); - style["backgroundColor"] = el.resolvedStyle.backgroundColor.ToString(); - obj["computed_style"] = style; + throw new System.ArgumentException("Writing C# scripts requires confirm: true because it triggers Unity compilation."); } - - var children = new JArray(); - foreach (var child in el.Children()) children.Add(SerializeVisualElement(child, deep)); - if (children.Count > 0) obj["children"] = children; - return obj; } } } diff --git a/Tests~/Editor/AssetDeleteSecurityTests.cs b/Tests~/Editor/AssetDeleteSecurityTests.cs new file mode 100644 index 0000000..2c7fcad --- /dev/null +++ b/Tests~/Editor/AssetDeleteSecurityTests.cs @@ -0,0 +1,192 @@ +using System.IO; +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace UnityMCP.Editor.Tests +{ + [TestFixture] + public class AssetDeleteSecurityTests + { + private static JObject CallRaw(string method, JObject paramsObj) + { + var request = new JObject + { + ["jsonrpc"] = "2.0", + ["method"] = method, + ["params"] = paramsObj, + ["id"] = 1 + }; + + string responseJson = MCPServerMethods.ProcessJsonRpc(request.ToString(Newtonsoft.Json.Formatting.None)); + return JObject.Parse(responseJson); + } + + private static void CleanupGeneratedAssetRoot(string root) + { + AssetDatabase.DeleteAsset(root); + + string absoluteRoot = MCPServerMethods.ValidatePath(root); + if (Directory.Exists(absoluteRoot)) Directory.Delete(absoluteRoot, true); + + string meta = absoluteRoot + ".meta"; + if (File.Exists(meta)) File.Delete(meta); + + AssetDatabase.Refresh(); + } + + [Test] + public void DeleteAsset_WithoutConfirm_ReturnsError() + { + string root = "Assets/NexusUnityGeneratedTests"; + string path = $"{root}/DeleteNoConfirm.txt"; + CleanupGeneratedAssetRoot(root); + + try + { + CallRaw("write_file", new JObject { ["path"] = path, ["content"] = "test content" }); + var res = CallRaw("delete_asset", new JObject { ["path"] = path }); + Assert.IsNotNull(res["error"], "delete_asset without confirm should return error"); + Assert.IsTrue(res["error"]["message"].ToString().Contains("confirm: true"), "Error message should mention confirm: true requirement"); + } + finally + { + CleanupGeneratedAssetRoot(root); + } + } + + [Test] + public void DeleteAsset_ProjectSettings_ReturnsError() + { + var res = CallRaw("delete_asset", new JObject { ["path"] = "ProjectSettings/ProjectSettings.asset", ["confirm"] = true }); + Assert.IsNotNull(res["error"], "delete_asset on ProjectSettings should return error"); + Assert.IsTrue(res["error"]["message"].ToString().Contains("forbidden"), "Error message should state ProjectSettings deletion is forbidden"); + } + + [Test] + public void DeleteAsset_AssetsRootFolder_ReturnsError() + { + var res = CallRaw("delete_asset", new JObject { ["path"] = "Assets", ["confirm"] = true }); + Assert.IsNotNull(res["error"], "delete_asset on Assets root folder should return error"); + Assert.IsTrue(res["error"]["message"].ToString().Contains("forbidden"), "Error message should state root folder deletion is forbidden"); + + var res2 = CallRaw("delete_asset", new JObject { ["path"] = "Assets/", ["confirm"] = true }); + Assert.IsNotNull(res2["error"], "delete_asset on Assets/ root folder should return error"); + Assert.IsTrue(res2["error"]["message"].ToString().Contains("forbidden"), "Error message should state root folder deletion is forbidden"); + } + + [Test] + public void DeleteAsset_MetaFileDirectly_ReturnsError() + { + var res = CallRaw("delete_asset", new JObject { ["path"] = "Assets/Test.cs.meta", ["confirm"] = true }); + Assert.IsNotNull(res["error"], "delete_asset on .meta file directly should return error"); + Assert.IsTrue(res["error"]["message"].ToString().Contains("Cannot delete .meta files directly"), "Error message should state .meta deletion is blocked"); + } + + [Test] + public void DeleteAsset_NonExistentFile_ReturnsError() + { + var res = CallRaw("delete_asset", new JObject { ["path"] = "Assets/NexusNonExistentAsset_12345.png", ["confirm"] = true }); + Assert.IsNotNull(res["error"], "delete_asset on non-existent file should return error"); + Assert.IsTrue(res["error"]["message"].ToString().Contains("Asset not found"), "Error message should state asset not found"); + } + + [Test] + public void DeleteAsset_WithConfirm_DeletesSuccessfully() + { + string root = "Assets/NexusUnityGeneratedTests"; + string path = $"{root}/DeleteWithConfirm.txt"; + CleanupGeneratedAssetRoot(root); + + try + { + CallRaw("write_file", new JObject { ["path"] = path, ["content"] = "test content" }); + Assert.IsTrue(File.Exists(MCPServerMethods.ValidatePath(path)), "Test file should exist before delete_asset"); + + var res = CallRaw("delete_asset", new JObject { ["path"] = path, ["confirm"] = true }); + Assert.IsNotNull(res["result"], $"Expected success result, got error: {res["error"]}"); + Assert.IsFalse(File.Exists(MCPServerMethods.ValidatePath(path)), "Test file should no longer exist after delete_asset"); + } + finally + { + CleanupGeneratedAssetRoot(root); + } + } + + [Test] + public void MoveAsset_ToProjectSettings_ReturnsError() + { + var res = CallRaw("move_asset", new JObject { ["old_path"] = "Assets/Test.txt", ["new_path"] = "ProjectSettings/ProjectSettings.asset" }); + Assert.IsNotNull(res["error"], "move_asset to ProjectSettings should return error"); + Assert.IsTrue(res["error"]["message"].ToString().Contains("forbidden"), "Error message should state modifying ProjectSettings is forbidden"); + } + + [Test] + public void MoveAsset_FromPackagesOrProjectSettings_ReturnsError() + { + var res = CallRaw("move_asset", new JObject { ["old_path"] = "Packages/com.unity.textmeshpro", ["new_path"] = "Assets/TMP_Backup" }); + Assert.IsNotNull(res["error"], "move_asset from Packages should return error"); + Assert.IsTrue(res["error"]["message"].ToString().Contains("forbidden"), "Error message should state modifying Packages is forbidden"); + } + + [Test] + public void MoveAsset_ToAssetsRoot_Succeeds() + { + string root = "Assets/NexusUnityGeneratedTests"; + string oldPath = $"{root}/Sub/MoveToRoot.txt"; + CleanupGeneratedAssetRoot(root); + + try + { + CallRaw("create_folder", new JObject { ["path"] = $"{root}/Sub" }); + CallRaw("write_file", new JObject { ["path"] = oldPath, ["content"] = "test" }); + AssetDatabase.Refresh(); + + var res = CallRaw("move_asset", new JObject { ["old_path"] = oldPath, ["new_path"] = root }); + Assert.IsNotNull(res["result"], $"Expected success result, got error: {res["error"]}"); + Assert.IsTrue(File.Exists(MCPServerMethods.ValidatePath($"{root}/MoveToRoot.txt")), "File should be moved into target folder"); + } + finally + { + CleanupGeneratedAssetRoot(root); + } + } + + [Test] + public void CopyAsset_ToProjectSettings_ReturnsError() + { + var res = CallRaw("copy_asset", new JObject { ["source_path"] = "Assets/Test.txt", ["dest_path"] = "ProjectSettings/ProjectSettings.asset" }); + Assert.IsNotNull(res["error"], "copy_asset to ProjectSettings should return error"); + Assert.IsTrue(res["error"]["message"].ToString().Contains("forbidden"), "Error message should state modifying ProjectSettings is forbidden"); + } + + [Test] + public void CreateFolder_InProjectSettingsOrPackages_ReturnsError() + { + var res1 = CallRaw("create_folder", new JObject { ["path"] = "ProjectSettings/ForbiddenDir" }); + Assert.IsNotNull(res1["error"], "create_folder in ProjectSettings should return error"); + Assert.IsTrue(res1["error"]["message"].ToString().Contains("forbidden"), "Error message should state modifying ProjectSettings is forbidden"); + + var res2 = CallRaw("create_folder", new JObject { ["path"] = "Packages/ForbiddenDir" }); + Assert.IsNotNull(res2["error"], "create_folder in Packages should return error"); + Assert.IsTrue(res2["error"]["message"].ToString().Contains("forbidden"), "Error message should state modifying Packages is forbidden"); + } + + [Test] + public void CreateMaterial_InProjectSettingsOrPackages_ReturnsError() + { + var res = CallRaw("create_material", new JObject { ["name"] = "BadMat", ["path"] = "ProjectSettings/BadMat.mat" }); + Assert.IsNotNull(res["error"], "create_material in ProjectSettings should return error"); + Assert.IsTrue(res["error"]["message"].ToString().Contains("forbidden"), "Error message should state modifying ProjectSettings is forbidden"); + } + + [Test] + public void ImportAsset_InProjectSettingsOrPackages_ReturnsError() + { + var res = CallRaw("import_asset", new JObject { ["path"] = "ProjectSettings/ProjectSettings.asset" }); + Assert.IsNotNull(res["error"], "import_asset in ProjectSettings should return error"); + Assert.IsTrue(res["error"]["message"].ToString().Contains("forbidden"), "Error message should state modifying ProjectSettings is forbidden"); + } + } +}