From f7e5c0830078c04c64944b2c2feb3eb61c4600ce Mon Sep 17 00:00:00 2001 From: ZeroGameStudio <837757433@qq.com> Date: Sun, 31 May 2026 13:22:45 +0800 Subject: [PATCH] Guard manage_asset search against broad scans --- MCPForUnity/Editor/Tools/ManageAsset.cs | 46 +++++---- Server/src/services/tools/manage_asset.py | 23 +++++ .../test_manage_asset_search_guard.py | 96 +++++++++++++++++++ .../Tools/ManageAssetSearchGuardTests.cs | 39 ++++++++ .../Tools/ManageAssetSearchGuardTests.cs.meta | 11 +++ 5 files changed, 199 insertions(+), 16 deletions(-) create mode 100644 Server/tests/integration/test_manage_asset_search_guard.py create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageAssetSearchGuardTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageAssetSearchGuardTests.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index 69aaf53cc..125fbeb8f 100644 --- a/MCPForUnity/Editor/Tools/ManageAsset.cs +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -627,37 +627,47 @@ private static object MoveOrRenameAsset(string path, string destinationPath) private static object SearchAssets(JObject @params) { - string searchPattern = @params["searchPattern"]?.ToString(); - string filterType = @params["filterType"]?.ToString(); - string pathScope = @params["path"]?.ToString(); // Use path as folder scope - string filterDateAfterStr = @params["filterDateAfter"]?.ToString(); + string searchPattern = @params["searchPattern"]?.ToString()?.Trim(); + string filterType = @params["filterType"]?.ToString()?.Trim(); + string pathScope = @params["path"]?.ToString()?.Trim(); // Use path as folder scope + string filterDateAfterStr = @params["filterDateAfter"]?.ToString()?.Trim(); int pageSize = @params["pageSize"]?.ToObject() ?? 50; // Default page size int pageNumber = @params["pageNumber"]?.ToObject() ?? 1; // Default page number (1-based) bool generatePreview = @params["generatePreview"]?.ToObject() ?? false; List searchFilters = new List(); - if (!string.IsNullOrEmpty(searchPattern)) + if (!string.IsNullOrWhiteSpace(searchPattern)) searchFilters.Add(searchPattern); - if (!string.IsNullOrEmpty(filterType)) + if (!string.IsNullOrWhiteSpace(filterType)) searchFilters.Add($"t:{filterType}"); string[] folderScope = null; - if (!string.IsNullOrEmpty(pathScope)) + if (!string.IsNullOrWhiteSpace(pathScope)) { folderScope = new string[] { AssetPathUtility.SanitizeAssetPath(pathScope) }; if (!AssetDatabase.IsValidFolder(folderScope[0])) { - // Maybe the user provided a file path instead of a folder? - // We could search in the containing folder, or return an error. - McpLog.Warn( - $"Search path '{folderScope[0]}' is not a valid folder. Searching entire project." + return new ErrorResponse( + $"Search path '{folderScope[0]}' is not a valid folder. " + + "Use a valid folder scope in 'path' or put the query in 'searchPattern'." ); - folderScope = null; // Search everywhere if path isn't a folder } } + if (searchFilters.Count == 0 && folderScope == null) + { + return new ErrorResponse( + "manage_asset search requires a valid folder scope in 'path' or a search filter such as 'searchPattern' or 'filterType'." + ); + } + + if (pageSize <= 0) + return new ErrorResponse("'pageSize' must be greater than zero."); + if (pageNumber <= 0) + return new ErrorResponse("'pageNumber' must be greater than zero."); + DateTime? filterDateAfter = null; - if (!string.IsNullOrEmpty(filterDateAfterStr)) + if (!string.IsNullOrWhiteSpace(filterDateAfterStr)) { if ( DateTime.TryParse( @@ -684,7 +694,7 @@ out DateTime parsedDate string.Join(" ", searchFilters), folderScope ); - List results = new List(); + List matchingPaths = new List(); int totalFound = 0; foreach (string guid in guids) @@ -706,12 +716,16 @@ out DateTime parsedDate } totalFound++; // Count matching assets before pagination - results.Add(GetAssetData(assetPath, generatePreview)); + matchingPaths.Add(assetPath); } // Apply pagination int startIndex = (pageNumber - 1) * pageSize; - var pagedResults = results.Skip(startIndex).Take(pageSize).ToList(); + var pagedResults = matchingPaths + .Skip(startIndex) + .Take(pageSize) + .Select(assetPath => GetAssetData(assetPath, generatePreview)) + .ToList(); return new SuccessResponse( $"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", diff --git a/Server/src/services/tools/manage_asset.py b/Server/src/services/tools/manage_asset.py index a0c7592d0..7528261d6 100644 --- a/Server/src/services/tools/manage_asset.py +++ b/Server/src/services/tools/manage_asset.py @@ -80,11 +80,25 @@ async def manage_asset( # Handle case where path is not a string despite type annotation raw_path = "" + def _looks_like_asset_folder_scope(value: str) -> bool: + normalized = value.replace("\\", "/").strip("/") + return ( + normalized == "Assets" + or normalized.startswith("Assets/") + or normalized == "Packages" + or normalized.startswith("Packages/") + ) + # If the caller put an AssetDatabase query into `path`, treat it as `search_pattern`. if (not search_pattern) and raw_path.startswith("t:"): search_pattern = raw_path path = "Assets" await ctx.info("manage_asset(search): normalized query from `path` into `search_pattern` and set path='Assets'") + elif raw_path and not _looks_like_asset_folder_scope(raw_path): + if not search_pattern: + search_pattern = raw_path + path = "Assets" + await ctx.info("manage_asset(search): normalized non-folder `path` into search criteria and set path='Assets'") # If the caller used `asset_type` to mean a search filter, map it to filter_type. # (In Unity, filterType becomes `t:`.) @@ -92,6 +106,15 @@ async def manage_asset( filter_type = asset_type await ctx.info("manage_asset(search): mapped `asset_type` into `filter_type` for safer server-side filtering") + if not any((raw_path, search_pattern, filter_type)): + return { + "success": False, + "message": ( + "manage_asset search requires a folder scope in `path` or at least one search criterion " + "such as `search_pattern` or `filter_type`." + ), + } + # Prepare parameters for the C# handler params_dict = { "action": action.lower(), diff --git a/Server/tests/integration/test_manage_asset_search_guard.py b/Server/tests/integration/test_manage_asset_search_guard.py new file mode 100644 index 000000000..9a746e87d --- /dev/null +++ b/Server/tests/integration/test_manage_asset_search_guard.py @@ -0,0 +1,96 @@ +import pytest + +from .test_helpers import DummyContext +from services.tools.manage_asset import manage_asset + + +@pytest.mark.asyncio +async def test_search_without_scope_or_filter_returns_error(monkeypatch): + async def fail_if_dispatched(*args, **kwargs): + raise AssertionError("empty asset search should not dispatch to Unity") + + monkeypatch.setattr( + "services.tools.manage_asset.send_with_unity_instance", + fail_if_dispatched, + ) + + result = await manage_asset( + ctx=DummyContext(), + action="search", + path="", + ) + + assert result["success"] is False + assert "search_pattern" in result["message"] + + +@pytest.mark.asyncio +async def test_search_date_filter_without_scope_or_filter_returns_error(monkeypatch): + async def fail_if_dispatched(*args, **kwargs): + raise AssertionError("date-only asset search should not dispatch to Unity") + + monkeypatch.setattr( + "services.tools.manage_asset.send_with_unity_instance", + fail_if_dispatched, + ) + + result = await manage_asset( + ctx=DummyContext(), + action="search", + path="", + filter_date_after="2026-01-01T00:00:00Z", + ) + + assert result["success"] is False + assert "filter_type" in result["message"] + + +@pytest.mark.asyncio +async def test_search_treats_non_folder_path_as_search_pattern(monkeypatch): + captured = {} + + async def fake_send(send_fn, unity_instance, command_type, params, **kwargs): + captured["command_type"] = command_type + captured["params"] = params + return {"success": True, "data": {"assets": []}} + + monkeypatch.setattr( + "services.tools.manage_asset.send_with_unity_instance", + fake_send, + ) + + result = await manage_asset( + ctx=DummyContext(), + action="search", + path="PlayerController", + ) + + assert result["success"] is True + assert captured["command_type"] == "manage_asset" + assert captured["params"]["path"] == "Assets" + assert captured["params"]["searchPattern"] == "PlayerController" + + +@pytest.mark.asyncio +async def test_search_keeps_folder_scope_when_filter_is_present(monkeypatch): + captured = {} + + async def fake_send(send_fn, unity_instance, command_type, params, **kwargs): + captured["params"] = params + return {"success": True, "data": {"assets": []}} + + monkeypatch.setattr( + "services.tools.manage_asset.send_with_unity_instance", + fake_send, + ) + + result = await manage_asset( + ctx=DummyContext(), + action="search", + path="Assets/Prefabs", + filter_type="Prefab", + ) + + assert result["success"] is True + assert captured["params"]["path"] == "Assets/Prefabs" + assert captured["params"]["filterType"] == "Prefab" diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageAssetSearchGuardTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageAssetSearchGuardTests.cs new file mode 100644 index 000000000..09da1101e --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageAssetSearchGuardTests.cs @@ -0,0 +1,39 @@ +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ManageAssetSearchGuardTests + { + [Test] + public void Search_WithoutScopeOrFilter_ReturnsError() + { + var result = ManageAsset.HandleCommand(new JObject + { + ["action"] = "search", + ["path"] = "" + }); + + var error = result as ErrorResponse; + Assert.IsNotNull(error, "Unfiltered project-wide search should be rejected."); + Assert.That(error.Error, Does.Contain("searchPattern")); + } + + [Test] + public void Search_WithInvalidFolderScope_ReturnsError() + { + var result = ManageAsset.HandleCommand(new JObject + { + ["action"] = "search", + ["path"] = "DefinitelyNotAUnityAssetFolder", + ["filterType"] = "Prefab" + }); + + var error = result as ErrorResponse; + Assert.IsNotNull(error, "Invalid search folder must not fall back to a full-project search."); + Assert.That(error.Error, Does.Contain("valid folder")); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageAssetSearchGuardTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageAssetSearchGuardTests.cs.meta new file mode 100644 index 000000000..c4480e6b6 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageAssetSearchGuardTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8cdf6ac62f7a1a9498ae4a19e4aac6d3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: