Skip to content
Closed
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
46 changes: 30 additions & 16 deletions MCPForUnity/Editor/Tools/ManageAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int?>() ?? 50; // Default page size
int pageNumber = @params["pageNumber"]?.ToObject<int?>() ?? 1; // Default page number (1-based)
bool generatePreview = @params["generatePreview"]?.ToObject<bool>() ?? false;

List<string> searchFilters = new List<string>();
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(
Expand All @@ -684,7 +694,7 @@ out DateTime parsedDate
string.Join(" ", searchFilters),
folderScope
);
List<object> results = new List<object>();
List<string> matchingPaths = new List<string>();
int totalFound = 0;

foreach (string guid in guids)
Expand All @@ -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).",
Expand Down
23 changes: 23 additions & 0 deletions Server/src/services/tools/manage_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,18 +80,41 @@ 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:<filterType>`.)
if (not filter_type) and asset_type and isinstance(asset_type, str):
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(),
Expand Down
96 changes: 96 additions & 0 deletions Server/tests/integration/test_manage_asset_search_guard.py
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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"));
}
}
}

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