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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions API_REFERENCE.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
93 changes: 43 additions & 50 deletions Editor/MCPServerMethods.Asset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -199,31 +180,29 @@ 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))
{
MergeDirectories(oldPath, newPath);
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" };
Expand All @@ -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}");
}
Expand All @@ -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);
}

Expand All @@ -263,16 +235,37 @@ 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<bool>() != 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" };
}

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" };
}
Expand All @@ -289,12 +282,12 @@ 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);
if (string.IsNullOrEmpty(guid)) throw new Exception("Failed to create folder");
return new JObject { ["status"] = "Success" };
}
}
}// Force Wed Apr 1 21:53:54 CEST 2026
}
69 changes: 69 additions & 0 deletions Editor/MCPServerMethods.SerializationUtils.cs
Original file line number Diff line number Diff line change
@@ -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<EditorWindow>().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;
}
}
}
11 changes: 11 additions & 0 deletions Editor/MCPServerMethods.SerializationUtils.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading