import sys from pathlib import Path import pytest import types # locate server src dynamically to avoid hardcoded layout assumptions ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # Stub telemetry modules to avoid file I/O during import of tools package telemetry = types.ModuleType("telemetry") def _noop(*args, **kwargs): pass class MilestoneType: # minimal placeholder pass telemetry.record_resource_usage = _noop telemetry.record_tool_usage = _noop telemetry.record_milestone = _noop telemetry.MilestoneType = MilestoneType telemetry.get_package_version = lambda: "0.0.0" sys.modules.setdefault("telemetry", telemetry) telemetry_decorator = types.ModuleType("telemetry_decorator") def telemetry_tool(*_args, **_kwargs): def _wrap(fn): return fn return _wrap telemetry_decorator.telemetry_tool = telemetry_tool sys.modules.setdefault("telemetry_decorator", telemetry_decorator) class DummyMCP: def __init__(self): self._tools = {} def tool(self, *args, **kwargs): # accept kwargs like description def deco(fn): self._tools[fn.__name__] = fn return fn return deco from tests.test_helpers import DummyContext @pytest.fixture() def resource_tools(): mcp = DummyMCP() # Import the tools module to trigger decorator registration import tools.resource_tools # Get the registered tools from the registry from registry import get_registered_tools tools = get_registered_tools() # Add all resource-related tools to our dummy MCP for tool_info in tools: tool_name = tool_info['name'] if any(keyword in tool_name for keyword in ['find_in_file', 'list_resources', 'read_resource']): mcp._tools[tool_name] = tool_info['func'] return mcp._tools def test_resource_list_filters_and_rejects_traversal(resource_tools, tmp_path, monkeypatch): # Create fake project structure proj = tmp_path assets = proj / "Assets" / "Scripts" assets.mkdir(parents=True) (assets / "A.cs").write_text("// a", encoding="utf-8") (assets / "B.txt").write_text("b", encoding="utf-8") outside = tmp_path / "Outside.cs" outside.write_text("// outside", encoding="utf-8") # Symlink attempting to escape sneaky_link = assets / "link_out" try: sneaky_link.symlink_to(outside) except Exception: # Some platforms may not allow symlinks in tests; ignore pass list_resources = resource_tools["list_resources"] # Only .cs under Assets should be listed import asyncio resp = asyncio.run( list_resources(ctx=DummyContext(), pattern="*.cs", under="Assets", limit=50, project_root=str(proj)) ) assert resp["success"] is True uris = resp["data"]["uris"] assert any(u.endswith("Assets/Scripts/A.cs") for u in uris) assert not any(u.endswith("B.txt") for u in uris) assert not any(u.endswith("Outside.cs") for u in uris) def test_resource_list_rejects_outside_paths(resource_tools, tmp_path): proj = tmp_path # under points outside Assets list_resources = resource_tools["list_resources"] import asyncio resp = asyncio.run( list_resources(ctx=DummyContext(), pattern="*.cs", under="..", limit=10, project_root=str(proj)) ) assert resp["success"] is False assert "Assets" in resp.get( "error", "") or "under project root" in resp.get("error", "")