From 8ae595d2f4f2525b0e44ece948883ea37138add4 Mon Sep 17 00:00:00 2001 From: dsarno Date: Fri, 10 Oct 2025 06:53:03 -0700 Subject: [PATCH 01/14] Update github-repo-stats.yml --- .github/workflows/github-repo-stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-repo-stats.yml b/.github/workflows/github-repo-stats.yml index fda0851b..6eb1eb72 100644 --- a/.github/workflows/github-repo-stats.yml +++ b/.github/workflows/github-repo-stats.yml @@ -1,10 +1,10 @@ name: github-repo-stats on: - schedule: + # schedule: # Run this once per day, towards the end of the day for keeping the most # recent data point most meaningful (hours are interpreted in UTC). - - cron: "0 23 * * *" + #- cron: "0 23 * * *" workflow_dispatch: # Allow for running this manually. jobs: From f8946c28a4b001d6d0eb1d27df376fabaad0d890 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 20 Oct 2025 19:40:20 -0700 Subject: [PATCH 02/14] pytest: make harness MCPForUnity-only; remove UnityMcpBridge paths from tests; route tools.manage_script via unity_connection for reliable monkeypatching; fix ctx usage; all tests green (39 pass, 5 skip, 7 xpass) --- .../src/tools/manage_script.py | 18 ++--- .../src/tools/manage_script.py | 16 ++-- tests/test_edit_normalization_and_noop.py | 66 ++++++++++++++--- tests/test_edit_strict_and_warnings.py | 26 +++++-- tests/test_find_in_file_minimal.py | 26 ++++++- tests/test_get_sha.py | 31 +++++++- tests/test_improved_anchor_matching.py | 2 +- tests/test_manage_script_uri.py | 59 +++++++++++---- tests/test_read_console_truncate.py | 41 ++++++++--- tests/test_read_resource_minimal.py | 26 ++++++- tests/test_resources_api.py | 61 ++++++++++------ tests/test_script_tools.py | 73 +++++++++++++++---- tests/test_telemetry_endpoint_validation.py | 32 ++++++-- tests/test_telemetry_subaction.py | 9 ++- tests/test_validate_script_summary.py | 31 +++++++- 15 files changed, 403 insertions(+), 114 deletions(-) diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py index cad6a88c..5c31e4bd 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py @@ -6,7 +6,7 @@ from mcp.server.fastmcp import FastMCP, Context from registry import mcp_for_unity_tool -from unity_connection import send_command_with_retry +import unity_connection def _split_uri(uri: str) -> tuple[str, str]: @@ -103,7 +103,7 @@ def _needs_normalization(arr: list[dict[str, Any]]) -> bool: warnings: list[str] = [] if _needs_normalization(edits): # Read file to support index->line/col conversion when needed - read_resp = send_command_with_retry("manage_script", { + read_resp = unity_connection.send_command_with_retry("manage_script", { "action": "read", "name": name, "path": directory, @@ -304,7 +304,7 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: "options": opts, } params = {k: v for k, v in params.items() if v is not None} - resp = send_command_with_retry("manage_script", params) + resp = unity_connection.send_command_with_retry("manage_script", params) if isinstance(resp, dict): data = resp.setdefault("data", {}) data.setdefault("normalizedEdits", normalized_edits) @@ -336,7 +336,7 @@ def _flip_async(): st = _latest_status() if st and st.get("reloading"): return - send_command_with_retry( + unity_connection.send_command_with_retry( "execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}, max_retries=0, @@ -386,7 +386,7 @@ def create_script( contents.encode("utf-8")).decode("utf-8") params["contentsEncoded"] = True params = {k: v for k, v in params.items() if v is not None} - resp = send_command_with_retry("manage_script", params) + resp = unity_connection.send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} @@ -401,7 +401,7 @@ def delete_script( if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} params = {"action": "delete", "name": name, "path": directory} - resp = send_command_with_retry("manage_script", params) + resp = unity_connection.send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} @@ -426,7 +426,7 @@ def validate_script( "path": directory, "level": level, } - resp = send_command_with_retry("manage_script", params) + resp = unity_connection.send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): diags = resp.get("data", {}).get("diagnostics", []) or [] warnings = sum(1 for d in diags if str( @@ -473,7 +473,7 @@ def manage_script( params = {k: v for k, v in params.items() if v is not None} - response = send_command_with_retry("manage_script", params) + response = unity_connection.send_command_with_retry("manage_script", params) if isinstance(response, dict): if response.get("success"): @@ -541,7 +541,7 @@ def get_sha( try: name, directory = _split_uri(uri) params = {"action": "get_sha", "name": name, "path": directory} - resp = send_command_with_retry("manage_script", params) + resp = unity_connection.send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): data = resp.get("data", {}) minimal = {"sha256": data.get( diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index cad6a88c..fa8c7bc4 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -6,7 +6,7 @@ from mcp.server.fastmcp import FastMCP, Context from registry import mcp_for_unity_tool -from unity_connection import send_command_with_retry +import unity_connection def _split_uri(uri: str) -> tuple[str, str]: @@ -103,7 +103,7 @@ def _needs_normalization(arr: list[dict[str, Any]]) -> bool: warnings: list[str] = [] if _needs_normalization(edits): # Read file to support index->line/col conversion when needed - read_resp = send_command_with_retry("manage_script", { + read_resp = unity_connection.send_command_with_retry("manage_script", { "action": "read", "name": name, "path": directory, @@ -304,7 +304,7 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: "options": opts, } params = {k: v for k, v in params.items() if v is not None} - resp = send_command_with_retry("manage_script", params) + resp = unity_connection.send_command_with_retry("manage_script", params) if isinstance(resp, dict): data = resp.setdefault("data", {}) data.setdefault("normalizedEdits", normalized_edits) @@ -336,7 +336,7 @@ def _flip_async(): st = _latest_status() if st and st.get("reloading"): return - send_command_with_retry( + unity_connection.send_command_with_retry( "execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}, max_retries=0, @@ -386,7 +386,7 @@ def create_script( contents.encode("utf-8")).decode("utf-8") params["contentsEncoded"] = True params = {k: v for k, v in params.items() if v is not None} - resp = send_command_with_retry("manage_script", params) + resp = unity_connection.send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} @@ -401,7 +401,7 @@ def delete_script( if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} params = {"action": "delete", "name": name, "path": directory} - resp = send_command_with_retry("manage_script", params) + resp = unity_connection.send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} @@ -473,7 +473,7 @@ def manage_script( params = {k: v for k, v in params.items() if v is not None} - response = send_command_with_retry("manage_script", params) + response = unity_connection.send_command_with_retry("manage_script", params) if isinstance(response, dict): if response.get("success"): @@ -541,7 +541,7 @@ def get_sha( try: name, directory = _split_uri(uri) params = {"action": "get_sha", "name": name, "path": directory} - resp = send_command_with_retry("manage_script", params) + resp = unity_connection.send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): data = resp.get("data", {}) minimal = {"sha256": data.get( diff --git a/tests/test_edit_normalization_and_noop.py b/tests/test_edit_normalization_and_noop.py index bf4e9b79..fd0fb03d 100644 --- a/tests/test_edit_normalization_and_noop.py +++ b/tests/test_edit_normalization_and_noop.py @@ -36,7 +36,7 @@ def _load(path: pathlib.Path, name: str): manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod2") manage_script_edits = _load( - SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod2") + SRC / "tools" / "script_apply_edits.py", "script_apply_edits_mod2") class DummyMCP: @@ -47,9 +47,30 @@ def deco(fn): self.tools[fn.__name__] = fn; return fn return deco +class DummyContext: + """Mock context object for testing""" + def info(self, message): + pass + + def warning(self, message): + pass + + def error(self, message): + pass + + def setup_tools(): mcp = DummyMCP() - manage_script.register_manage_script_tools(mcp) + # Import the tools module to trigger decorator registration + import tools.manage_script + # Get the registered tools from the registry + from registry import get_registered_tools + tools = get_registered_tools() + # Add all script-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 ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']): + mcp.tools[tool_name] = tool_info['func'] return mcp.tools @@ -62,14 +83,18 @@ def fake_send(cmd, params): calls.append(params) return {"success": True} - monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, + "send_command_with_retry", fake_send) + # No need to patch tools.manage_script; it calls unity_connection.send_command_with_retry # LSP-style edits = [{ "range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}}, "newText": "// lsp\n" }] - apply(None, uri="unity://path/Assets/Scripts/F.cs", + apply(DummyContext(), uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x") p = calls[-1] e = p["edits"][0] @@ -84,8 +109,10 @@ def fake_read(cmd, params): if params.get("action") == "read": return {"success": True, "data": {"contents": "hello\n"}} return {"success": True} - monkeypatch.setattr(manage_script, "send_command_with_retry", fake_read) - apply(None, uri="unity://path/Assets/Scripts/F.cs", + + # Override unity_connection for this read normalization case + monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_read) + apply(DummyContext(), uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x") # last call is apply_text_edits @@ -97,9 +124,13 @@ def test_noop_evidence_shape(monkeypatch): def fake_send(cmd, params): return {"success": True, "data": {"no_op": True, "evidence": {"reason": "identical_content"}}} - monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, + "send_command_with_retry", fake_send) + # No need to patch tools.manage_script; it calls unity_connection.send_command_with_retry - resp = apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=[ + resp = apply(DummyContext(), uri="unity://path/Assets/Scripts/F.cs", edits=[ {"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": ""}], precondition_sha256="x") assert resp["success"] is True assert resp.get("data", {}).get("no_op") is True @@ -109,7 +140,16 @@ def test_atomic_multi_span_and_relaxed(monkeypatch): tools_text = setup_tools() apply_text = tools_text["apply_text_edits"] tools_struct = DummyMCP() - manage_script_edits.register_manage_script_edits_tools(tools_struct) + # Import the tools module to trigger decorator registration + import tools.script_apply_edits + # Get the registered tools from the registry + from registry import get_registered_tools + tools = get_registered_tools() + # Add all script-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 ['script_apply', 'apply_edits']): + tools_struct.tools[tool_name] = tool_info['func'] # Fake send for read and write; verify atomic applyMode and validate=relaxed passes through sent = {} @@ -118,14 +158,18 @@ def fake_send(cmd, params): return {"success": True, "data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"}} sent.setdefault("calls", []).append(params) return {"success": True} - monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, + "send_command_with_retry", fake_send) edits = [ {"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"}, {"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"} ] - resp = apply_text(None, uri="unity://path/Assets/Scripts/C.cs", edits=edits, + resp = apply_text(DummyContext(), uri="unity://path/Assets/Scripts/C.cs", edits=edits, precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"}) assert resp["success"] is True # Last manage_script call should include options with applyMode atomic and validate relaxed diff --git a/tests/test_edit_strict_and_warnings.py b/tests/test_edit_strict_and_warnings.py index 64b4843c..7f30b4f8 100644 --- a/tests/test_edit_strict_and_warnings.py +++ b/tests/test_edit_strict_and_warnings.py @@ -47,8 +47,22 @@ def deco(fn): self.tools[fn.__name__] = fn; return fn def setup_tools(): mcp = DummyMCP() - manage_script.register_manage_script_tools(mcp) + # Import tools to trigger decorator-based registration + import tools.manage_script + from registry import get_registered_tools + for tool_info in get_registered_tools(): + name = tool_info['name'] + if any(k in name for k in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']): + mcp.tools[name] = tool_info['func'] return mcp.tools +class DummyContext: + def info(self, *args, **kwargs): + pass + def warning(self, *args, **kwargs): + pass + def error(self, *args, **kwargs): + pass + def test_explicit_zero_based_normalized_warning(monkeypatch): @@ -59,12 +73,13 @@ def fake_send(cmd, params): # Simulate Unity path returning minimal success return {"success": True} - monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + import unity_connection + monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_send) # Explicit fields given as 0-based (invalid); SDK should normalize and warn edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}] - resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", + resp = apply_edits(DummyContext(), uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha") assert resp["success"] is True @@ -83,11 +98,12 @@ def test_strict_zero_based_error(monkeypatch): def fake_send(cmd, params): return {"success": True} - monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + import unity_connection + monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_send) edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}] - resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", + resp = apply_edits(DummyContext(), uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha", strict=True) assert resp["success"] is False assert resp.get("code") == "zero_based_explicit_fields" diff --git a/tests/test_find_in_file_minimal.py b/tests/test_find_in_file_minimal.py index 92216f60..c068f51c 100644 --- a/tests/test_find_in_file_minimal.py +++ b/tests/test_find_in_file_minimal.py @@ -1,4 +1,3 @@ -from tools.resource_tools import register_resource_tools # type: ignore import sys import pathlib import importlib.util @@ -22,10 +21,31 @@ def deco(fn): return deco +class DummyContext: + """Mock context object for testing""" + def info(self, message): + pass + + def warning(self, message): + pass + + def error(self, message): + pass + + @pytest.fixture() def resource_tools(): mcp = DummyMCP() - register_resource_tools(mcp) + # 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 @@ -40,7 +60,7 @@ def test_find_in_file_returns_positions(resource_tools, tmp_path): try: resp = loop.run_until_complete( find_in_file(uri="unity://path/Assets/A.txt", - pattern="world", ctx=None, project_root=str(proj)) + pattern="world", ctx=DummyContext(), project_root=str(proj)) ) finally: loop.close() diff --git a/tests/test_get_sha.py b/tests/test_get_sha.py index 65b59b01..49e22f4f 100644 --- a/tests/test_get_sha.py +++ b/tests/test_get_sha.py @@ -49,9 +49,30 @@ def deco(fn): return deco +class DummyContext: + """Mock context object for testing""" + def info(self, message): + pass + + def warning(self, message): + pass + + def error(self, message): + pass + + def setup_tools(): mcp = DummyMCP() - manage_script.register_manage_script_tools(mcp) + # Import the tools module to trigger decorator registration + import tools.manage_script + # Get the registered tools from the registry + from registry import get_registered_tools + tools = get_registered_tools() + # Add all script-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 ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']): + mcp.tools[tool_name] = tool_info['func'] return mcp.tools @@ -66,9 +87,13 @@ def fake_send(cmd, params): captured["params"] = params return {"success": True, "data": {"sha256": "abc", "lengthBytes": 1, "lastModifiedUtc": "2020-01-01T00:00:00Z", "uri": "unity://path/Assets/Scripts/A.cs", "path": "Assets/Scripts/A.cs"}} - monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, + "send_command_with_retry", fake_send) + # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry - resp = get_sha(None, uri="unity://path/Assets/Scripts/A.cs") + resp = get_sha(DummyContext(), uri="unity://path/Assets/Scripts/A.cs") assert captured["cmd"] == "manage_script" assert captured["params"]["action"] == "get_sha" assert captured["params"]["name"] == "A" diff --git a/tests/test_improved_anchor_matching.py b/tests/test_improved_anchor_matching.py index cf3ced1f..28af7f35 100644 --- a/tests/test_improved_anchor_matching.py +++ b/tests/test_improved_anchor_matching.py @@ -39,7 +39,7 @@ def load_module(path, name): manage_script_edits_module = load_module( - SRC / "tools" / "manage_script_edits.py", "manage_script_edits_module") + SRC / "tools" / "script_apply_edits.py", "script_apply_edits_module") def test_improved_anchor_matching(): diff --git a/tests/test_manage_script_uri.py b/tests/test_manage_script_uri.py index 7f05f8e1..41db22eb 100644 --- a/tests/test_manage_script_uri.py +++ b/tests/test_manage_script_uri.py @@ -58,14 +58,35 @@ class DummyCtx: # FastMCP Context placeholder pass +class DummyContext: + """Mock context object for testing""" + def info(self, message): + pass + + def warning(self, message): + pass + + def error(self, message): + pass + + def _register_tools(): mcp = DummyMCP() - manage_script.register_manage_script_tools(mcp) # populates mcp.tools + # Import the tools module to trigger decorator registration + import tools.manage_script as manage_script_module + # Get the registered tools from the registry + from registry import get_registered_tools + registered_tools = get_registered_tools() + # Add all script-related tools to our dummy MCP + for tool_info in registered_tools: + tool_name = tool_info['name'] + if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']): + mcp.tools[tool_name] = tool_info['func'] return mcp.tools def test_split_uri_unity_path(monkeypatch): - tools = _register_tools() + test_tools = _register_tools() captured = {} def fake_send(cmd, params): # capture params and return success @@ -73,11 +94,15 @@ def fake_send(cmd, params): # capture params and return success captured['params'] = params return {"success": True, "message": "ok"} - monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, + "send_command_with_retry", fake_send) + # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry - fn = tools['apply_text_edits'] + fn = test_tools['apply_text_edits'] uri = "unity://path/Assets/Scripts/MyScript.cs" - fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None) + fn(DummyContext(), uri=uri, edits=[], precondition_sha256=None) assert captured['cmd'] == 'manage_script' assert captured['params']['name'] == 'MyScript' @@ -97,7 +122,7 @@ def fake_send(cmd, params): # capture params and return success ], ) def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path): - tools = _register_tools() + test_tools = _register_tools() captured = {} def fake_send(cmd, params): @@ -105,27 +130,35 @@ def fake_send(cmd, params): captured['params'] = params return {"success": True, "message": "ok"} - monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, + "send_command_with_retry", fake_send) + # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry - fn = tools['apply_text_edits'] - fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None) + fn = test_tools['apply_text_edits'] + fn(DummyContext(), uri=uri, edits=[], precondition_sha256=None) assert captured['params']['name'] == expected_name assert captured['params']['path'] == expected_path def test_split_uri_plain_path(monkeypatch): - tools = _register_tools() + test_tools = _register_tools() captured = {} def fake_send(cmd, params): captured['params'] = params return {"success": True, "message": "ok"} - monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, + "send_command_with_retry", fake_send) + # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry - fn = tools['apply_text_edits'] - fn(DummyCtx(), uri="Assets/Scripts/Thing.cs", + fn = test_tools['apply_text_edits'] + fn(DummyContext(), uri="Assets/Scripts/Thing.cs", edits=[], precondition_sha256=None) assert captured['params']['name'] == 'Thing' diff --git a/tests/test_read_console_truncate.py b/tests/test_read_console_truncate.py index dab8f904..c5726416 100644 --- a/tests/test_read_console_truncate.py +++ b/tests/test_read_console_truncate.py @@ -48,9 +48,30 @@ def deco(fn): return deco +class DummyContext: + """Mock context object for testing""" + def info(self, message): + pass + + def warning(self, message): + pass + + def error(self, message): + pass + + def setup_tools(): mcp = DummyMCP() - read_console_mod.register_read_console_tools(mcp) + # Import the tools module to trigger decorator registration + import tools.read_console + # Get the registered tools from the registry + from registry import get_registered_tools + registered_tools = get_registered_tools() + # Add all console-related tools to our dummy MCP + for tool_info in registered_tools: + tool_name = tool_info['name'] + if any(keyword in tool_name for keyword in ['read_console', 'console']): + mcp.tools[tool_name] = tool_info['func'] return mcp.tools @@ -67,11 +88,12 @@ def fake_send(cmd, params): "data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]}, } - monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send) - monkeypatch.setattr( - read_console_mod, "get_unity_connection", lambda: object()) + # Patch the send_command_with_retry function in the tools module + import tools.read_console + monkeypatch.setattr(tools.read_console, + "send_command_with_retry", fake_send) - resp = read_console(ctx=None, count=10) + resp = read_console(ctx=DummyContext(), action="get", count=10) assert resp == { "success": True, "data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]}, @@ -93,11 +115,12 @@ def fake_send(cmd, params): "data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace"}]}, } - monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send) - monkeypatch.setattr( - read_console_mod, "get_unity_connection", lambda: object()) + # Patch the send_command_with_retry function in the tools module + import tools.read_console + monkeypatch.setattr(tools.read_console, + "send_command_with_retry", fake_send) - resp = read_console(ctx=None, count=10, include_stacktrace=False) + resp = read_console(ctx=DummyContext(), action="get", count=10, include_stacktrace=False) assert resp == {"success": True, "data": { "lines": [{"level": "error", "message": "oops"}]}} assert captured["params"]["includeStacktrace"] is False diff --git a/tests/test_read_resource_minimal.py b/tests/test_read_resource_minimal.py index 10ecf33f..be9ec6cd 100644 --- a/tests/test_read_resource_minimal.py +++ b/tests/test_read_resource_minimal.py @@ -1,4 +1,3 @@ -from tools.resource_tools import register_resource_tools # type: ignore import sys import pathlib import asyncio @@ -39,10 +38,31 @@ def deco(fn): return deco +class DummyContext: + """Mock context object for testing""" + def info(self, message): + pass + + def warning(self, message): + pass + + def error(self, message): + pass + + @pytest.fixture() def resource_tools(): mcp = DummyMCP() - register_resource_tools(mcp) + # 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 @@ -59,7 +79,7 @@ def test_read_resource_minimal_metadata_only(resource_tools, tmp_path): try: resp = loop.run_until_complete( read_resource(uri="unity://path/Assets/A.txt", - ctx=None, project_root=str(proj)) + ctx=DummyContext(), project_root=str(proj)) ) finally: loop.close() diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py index 7d2f7803..f80c9b9c 100644 --- a/tests/test_resources_api.py +++ b/tests/test_resources_api.py @@ -1,4 +1,4 @@ -from tools.resource_tools import register_resource_tools # type: ignore +# Fixed import - using decorator-based registration instead import pytest @@ -9,17 +9,7 @@ # locate server src dynamically to avoid hardcoded layout assumptions ROOT = Path(__file__).resolve().parents[1] -candidates = [ - ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", - ROOT / "UnityMcpServer~" / "src", -] -SRC = next((p for p in candidates if p.exists()), None) -if SRC is None: - searched = "\n".join(str(p) for p in candidates) - pytest.skip( - "MCP for Unity server source not found. Tried:\n" + searched, - allow_module_level=True, - ) +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) @@ -34,10 +24,31 @@ def deco(fn): return deco +class DummyContext: + """Mock context object for testing""" + def info(self, message): + pass + + def warning(self, message): + pass + + def error(self, message): + pass + + @pytest.fixture() def resource_tools(): mcp = DummyMCP() - register_resource_tools(mcp) + # 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 @@ -61,10 +72,14 @@ def test_resource_list_filters_and_rejects_traversal(resource_tools, tmp_path, m list_resources = resource_tools["list_resources"] # Only .cs under Assets should be listed import asyncio - resp = asyncio.get_event_loop().run_until_complete( - list_resources(ctx=None, pattern="*.cs", under="Assets", - limit=50, project_root=str(proj)) - ) + loop = asyncio.new_event_loop() + try: + resp = loop.run_until_complete( + list_resources(ctx=DummyContext(), pattern="*.cs", under="Assets", + limit=50, project_root=str(proj)) + ) + finally: + loop.close() assert resp["success"] is True uris = resp["data"]["uris"] assert any(u.endswith("Assets/Scripts/A.cs") for u in uris) @@ -77,10 +92,14 @@ def test_resource_list_rejects_outside_paths(resource_tools, tmp_path): # under points outside Assets list_resources = resource_tools["list_resources"] import asyncio - resp = asyncio.get_event_loop().run_until_complete( - list_resources(ctx=None, pattern="*.cs", under="..", - limit=10, project_root=str(proj)) - ) + loop = asyncio.new_event_loop() + try: + resp = loop.run_until_complete( + list_resources(ctx=DummyContext(), pattern="*.cs", under="..", + limit=10, project_root=str(proj)) + ) + finally: + loop.close() assert resp["success"] is False assert "Assets" in resp.get( "error", "") or "under project root" in resp.get("error", "") diff --git a/tests/test_script_tools.py b/tests/test_script_tools.py index f6e3c8a1..65ae33e4 100644 --- a/tests/test_script_tools.py +++ b/tests/test_script_tools.py @@ -53,15 +53,45 @@ def decorator(func): return decorator +class DummyContext: + """Mock context object for testing""" + def info(self, message): + pass + + def warning(self, message): + pass + + def error(self, message): + pass + + def setup_manage_script(): mcp = DummyMCP() - manage_script_module.register_manage_script_tools(mcp) + # Import the tools module to trigger decorator registration + import tools.manage_script + # Get the registered tools from the registry + from registry import get_registered_tools + tools = get_registered_tools() + # Add all script-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 ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']): + mcp.tools[tool_name] = tool_info['func'] return mcp.tools def setup_manage_asset(): mcp = DummyMCP() - manage_asset_module.register_manage_asset_tools(mcp) + # Import the tools module to trigger decorator registration + import tools.manage_asset + # Get the registered tools from the registry + from registry import get_registered_tools + tools = get_registered_tools() + # Add all asset-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 ['asset', 'manage_asset']): + mcp.tools[tool_name] = tool_info['func'] return mcp.tools @@ -75,12 +105,16 @@ def fake_send(cmd, params): captured["params"] = params return {"success": True} - monkeypatch.setattr(manage_script_module, + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_send) + # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry edit = {"startLine": 1005, "startCol": 0, "endLine": 1005, "endCol": 5, "newText": "Hello"} - resp = apply_edits(None, "unity://path/Assets/Scripts/LongFile.cs", [edit]) + ctx = DummyContext() + resp = apply_edits(ctx, "unity://path/Assets/Scripts/LongFile.cs", [edit]) assert captured["cmd"] == "manage_script" assert captured["params"]["action"] == "apply_text_edits" assert captured["params"]["edits"][0]["startLine"] == 1005 @@ -96,15 +130,18 @@ def fake_send(cmd, params): calls.append(params) return {"success": True, "sha256": f"hash{len(calls)}"} - monkeypatch.setattr(manage_script_module, + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_send) + # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry edit1 = {"startLine": 1, "startCol": 0, "endLine": 1, "endCol": 0, "newText": "//header\n"} - resp1 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit1]) + resp1 = apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs", [edit1]) edit2 = {"startLine": 2, "startCol": 0, "endLine": 2, "endCol": 0, "newText": "//second\n"} - resp2 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", + resp2 = apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs", [edit2], precondition_sha256=resp1["sha256"]) assert calls[1]["precondition_sha256"] == resp1["sha256"] @@ -120,11 +157,14 @@ def fake_send(cmd, params): captured["params"] = params return {"success": True} - monkeypatch.setattr(manage_script_module, + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_send) + # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"} - apply_edits(None, "unity://path/Assets/Scripts/File.cs", + apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs", [{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": "x"}], options=opts) assert captured["params"].get("options") == opts @@ -138,15 +178,18 @@ def fake_send(cmd, params): captured["params"] = params return {"success": True} - monkeypatch.setattr(manage_script_module, + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_send) + # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry edits = [ {"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"}, {"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"}, ] - apply_edits(None, "unity://path/Assets/Scripts/File.cs", + apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs", edits, precondition_sha256="x") opts = captured["params"].get("options", {}) assert opts.get("applyMode") == "atomic" @@ -162,14 +205,14 @@ async def fake_async(cmd, params, loop=None): captured["params"] = params return {"success": True} - monkeypatch.setattr(manage_asset_module, + # Patch the async function in the tools module + import tools.manage_asset + monkeypatch.setattr(tools.manage_asset, "async_send_command_with_retry", fake_async) - monkeypatch.setattr(manage_asset_module, - "get_unity_connection", lambda: object()) async def run(): resp = await manage_asset( - None, + DummyContext(), action="modify", path="Assets/Prefabs/Player.prefab", properties={"hp": 100}, diff --git a/tests/test_telemetry_endpoint_validation.py b/tests/test_telemetry_endpoint_validation.py index c896860d..56956d39 100644 --- a/tests/test_telemetry_endpoint_validation.py +++ b/tests/test_telemetry_endpoint_validation.py @@ -7,8 +7,14 @@ def test_endpoint_rejects_non_http(tmp_path, monkeypatch): monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "file:///etc/passwd") - telemetry = importlib.import_module( - "MCPForUnity.UnityMcpServer~.src.telemetry") + # Import the telemetry module from the correct path + import sys + import pathlib + ROOT = pathlib.Path(__file__).resolve().parents[1] + SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" + sys.path.insert(0, str(SRC)) + + telemetry = importlib.import_module("telemetry") importlib.reload(telemetry) tc = telemetry.TelemetryCollector() @@ -23,13 +29,17 @@ def test_config_preferred_then_env_override(tmp_path, monkeypatch): # Patch config.telemetry_endpoint via import mocking import importlib - cfg_mod = importlib.import_module( - "MCPForUnity.UnityMcpServer~.src.config") + import sys + import pathlib + ROOT = pathlib.Path(__file__).resolve().parents[1] + SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" + sys.path.insert(0, str(SRC)) + + cfg_mod = importlib.import_module("config") old_endpoint = cfg_mod.config.telemetry_endpoint cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry" try: - telemetry = importlib.import_module( - "MCPForUnity.UnityMcpServer~.src.telemetry") + telemetry = importlib.import_module("telemetry") importlib.reload(telemetry) tc = telemetry.TelemetryCollector() assert tc.config.endpoint == "https://example.com/telemetry" @@ -47,8 +57,14 @@ def test_config_preferred_then_env_override(tmp_path, monkeypatch): def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch): monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) - telemetry = importlib.import_module( - "MCPForUnity.UnityMcpServer~.src.telemetry") + # Import the telemetry module from the correct path + import sys + import pathlib + ROOT = pathlib.Path(__file__).resolve().parents[1] + SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" + sys.path.insert(0, str(SRC)) + + telemetry = importlib.import_module("telemetry") importlib.reload(telemetry) tc1 = telemetry.TelemetryCollector() diff --git a/tests/test_telemetry_subaction.py b/tests/test_telemetry_subaction.py index 8c0489ee..5e209043 100644 --- a/tests/test_telemetry_subaction.py +++ b/tests/test_telemetry_subaction.py @@ -3,8 +3,13 @@ def _get_decorator_module(): # Import the telemetry_decorator module from the MCP for Unity server src - mod = importlib.import_module( - "MCPForUnity.UnityMcpServer~.src.telemetry_decorator") + import sys + import pathlib + ROOT = pathlib.Path(__file__).resolve().parents[1] + SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" + sys.path.insert(0, str(SRC)) + + mod = importlib.import_module("telemetry_decorator") return mod diff --git a/tests/test_validate_script_summary.py b/tests/test_validate_script_summary.py index 971b52b7..c8d48e16 100644 --- a/tests/test_validate_script_summary.py +++ b/tests/test_validate_script_summary.py @@ -48,9 +48,30 @@ def deco(fn): return deco +class DummyContext: + """Mock context object for testing""" + def info(self, message): + pass + + def warning(self, message): + pass + + def error(self, message): + pass + + def setup_tools(): mcp = DummyMCP() - manage_script.register_manage_script_tools(mcp) + # Import the tools module to trigger decorator registration + import tools.manage_script + # Get the registered tools from the registry + from registry import get_registered_tools + registered_tools = get_registered_tools() + # Add all script-related tools to our dummy MCP + for tool_info in registered_tools: + tool_name = tool_info['name'] + if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']): + mcp.tools[tool_name] = tool_info['func'] return mcp.tools @@ -70,7 +91,11 @@ def fake_send(cmd, params): }, } - monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) + # Patch the send_command_with_retry function at the module level where it's imported + import unity_connection + monkeypatch.setattr(unity_connection, + "send_command_with_retry", fake_send) + # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry - resp = validate_script(None, uri="unity://path/Assets/Scripts/A.cs") + resp = validate_script(DummyContext(), uri="unity://path/Assets/Scripts/A.cs") assert resp == {"success": True, "data": {"warnings": 1, "errors": 2}} From 15bd3f3f9e41bab4af29b344be9b733e685c3f79 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 20 Oct 2025 20:29:43 -0700 Subject: [PATCH 03/14] Add missing meta for MaterialMeshInstantiationTests.cs (Assets) --- .../Tools/MaterialMeshInstantiationTests.cs.meta | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialMeshInstantiationTests.cs.meta diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialMeshInstantiationTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialMeshInstantiationTests.cs.meta new file mode 100644 index 00000000..6f734f9a --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialMeshInstantiationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f67ba1d248b564c97b1afa12caae0196 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From a729600edb4905e88af0ed3d8532a66e49a5db1f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 20 Oct 2025 20:54:00 -0700 Subject: [PATCH 04/14] bridge/tools/manage_script: fix missing unity_connection prefix in validate_script; tests: tidy manage_script_uri unused symbols and arg names --- .../UnityMcpServer~/src/tools/manage_script.py | 2 +- tests/test_manage_script_uri.py | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index fa8c7bc4..5c31e4bd 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -426,7 +426,7 @@ def validate_script( "path": directory, "level": level, } - resp = send_command_with_retry("manage_script", params) + resp = unity_connection.send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): diags = resp.get("data", {}).get("diagnostics", []) or [] warnings = sum(1 for d in diags if str( diff --git a/tests/test_manage_script_uri.py b/tests/test_manage_script_uri.py index 41db22eb..19112141 100644 --- a/tests/test_manage_script_uri.py +++ b/tests/test_manage_script_uri.py @@ -1,4 +1,4 @@ -import tools.manage_script as manage_script # type: ignore +# import triggers registration elsewhere; no direct use here import sys import types from pathlib import Path @@ -54,8 +54,7 @@ def _decorator(fn): return _decorator -class DummyCtx: # FastMCP Context placeholder - pass +# (removed unused DummyCtx) class DummyContext: @@ -73,7 +72,7 @@ def error(self, message): def _register_tools(): mcp = DummyMCP() # Import the tools module to trigger decorator registration - import tools.manage_script as manage_script_module + import tools.manage_script # trigger decorator registration # Get the registered tools from the registry from registry import get_registered_tools registered_tools = get_registered_tools() @@ -125,8 +124,8 @@ def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path): test_tools = _register_tools() captured = {} - def fake_send(cmd, params): - captured['cmd'] = cmd + def fake_send(_cmd, params): + captured['cmd'] = _cmd captured['params'] = params return {"success": True, "message": "ok"} @@ -147,7 +146,7 @@ def test_split_uri_plain_path(monkeypatch): test_tools = _register_tools() captured = {} - def fake_send(cmd, params): + def fake_send(_cmd, params): captured['params'] = params return {"success": True, "message": "ok"} From b0edffd79d042e4bccf23e2b6a23ed28d8460c0e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 20 Oct 2025 21:01:18 -0700 Subject: [PATCH 05/14] tests: rename to script_apply_edits_module; extract DummyContext to tests/test_helpers and import; add telemetry stubs in tests to avoid pyproject I/O --- tests/test_edit_strict_and_warnings.py | 30 ++++++++++++++++------ tests/test_find_in_file_minimal.py | 35 +++++++++++++++++--------- tests/test_helpers.py | 10 ++++++++ tests/test_improved_anchor_matching.py | 8 +++--- tests/test_resources_api.py | 32 +++++++++++++++-------- 5 files changed, 81 insertions(+), 34 deletions(-) create mode 100644 tests/test_helpers.py diff --git a/tests/test_edit_strict_and_warnings.py b/tests/test_edit_strict_and_warnings.py index 7f30b4f8..51a04bd0 100644 --- a/tests/test_edit_strict_and_warnings.py +++ b/tests/test_edit_strict_and_warnings.py @@ -8,6 +8,27 @@ 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: + 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(*dargs, **dkwargs): + def _wrap(fn): + return fn + return _wrap +telemetry_decorator.telemetry_tool = telemetry_tool +sys.modules.setdefault("telemetry_decorator", telemetry_decorator) + # stub mcp.server.fastmcp mcp_pkg = types.ModuleType("mcp") server_pkg = types.ModuleType("mcp.server") @@ -55,14 +76,7 @@ def setup_tools(): if any(k in name for k in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']): mcp.tools[name] = tool_info['func'] return mcp.tools -class DummyContext: - def info(self, *args, **kwargs): - pass - def warning(self, *args, **kwargs): - pass - def error(self, *args, **kwargs): - pass - +from tests.test_helpers import DummyContext def test_explicit_zero_based_normalized_warning(monkeypatch): diff --git a/tests/test_find_in_file_minimal.py b/tests/test_find_in_file_minimal.py index c068f51c..0d49dc09 100644 --- a/tests/test_find_in_file_minimal.py +++ b/tests/test_find_in_file_minimal.py @@ -5,10 +5,33 @@ import asyncio import pytest +from tests.test_helpers import DummyContext + ROOT = pathlib.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: + 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(*dargs, **dkwargs): + 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): @@ -21,18 +44,6 @@ def deco(fn): return deco -class DummyContext: - """Mock context object for testing""" - def info(self, message): - pass - - def warning(self, message): - pass - - def error(self, message): - pass - - @pytest.fixture() def resource_tools(): mcp = DummyMCP() diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 00000000..cc732361 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,10 @@ +class DummyContext: + """Mock context object for testing""" + def info(self, message): + pass + + def warning(self, message): + pass + + def error(self, message): + pass diff --git a/tests/test_improved_anchor_matching.py b/tests/test_improved_anchor_matching.py index 28af7f35..32d30510 100644 --- a/tests/test_improved_anchor_matching.py +++ b/tests/test_improved_anchor_matching.py @@ -38,7 +38,7 @@ def load_module(path, name): return module -manage_script_edits_module = load_module( +script_apply_edits_module = load_module( SRC / "tools" / "script_apply_edits.py", "script_apply_edits_module") @@ -67,7 +67,7 @@ def test_improved_anchor_matching(): flags = re.MULTILINE # Test our improved function - best_match = manage_script_edits_module._find_best_anchor_match( + best_match = script_apply_edits_module._find_best_anchor_match( anchor_pattern, test_code, flags, prefer_last=True ) @@ -116,7 +116,7 @@ def test_old_vs_new_matching(): '\n') + 1 if old_match else None # New behavior (improved matching) - new_match = manage_script_edits_module._find_best_anchor_match( + new_match = script_apply_edits_module._find_best_anchor_match( anchor_pattern, test_code, flags, prefer_last=True ) new_line = test_code[:new_match.start()].count( @@ -152,7 +152,7 @@ def test_apply_edits_with_improved_matching(): "text": "\n public void NewMethod() { Debug.Log(\"Added at class end\"); }\n" }] - result = manage_script_edits_module._apply_edits_locally( + result = script_apply_edits_module._apply_edits_locally( original_code, edits) lines = result.split('\n') try: diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py index f80c9b9c..cfa71e38 100644 --- a/tests/test_resources_api.py +++ b/tests/test_resources_api.py @@ -12,6 +12,27 @@ 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(*dargs, **dkwargs): + 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): @@ -24,16 +45,7 @@ def deco(fn): return deco -class DummyContext: - """Mock context object for testing""" - def info(self, message): - pass - - def warning(self, message): - pass - - def error(self, message): - pass +from tests.test_helpers import DummyContext @pytest.fixture() From 282f4d8b32eefaf90a7d9313a385e9d4f03ee004 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 20 Oct 2025 21:34:12 -0700 Subject: [PATCH 06/14] tests: import cleanup and helper extraction; telemetry: prefer plain config and opt-in env override; test stubs and CWD fixes; exclude Bridge from pytest discovery --- .wt-origin-main | 1 + MCPForUnity/UnityMcpServer~/src/telemetry.py | 15 ++++++---- TestProjects/UnityMCPTests/Assets/Temp.meta | 8 +++++ .../UnityMcpServer~/src/conftest.py | 4 +++ pytest.ini | 4 +++ tests/conftest.py | 20 +++++++++++++ tests/test_edit_strict_and_warnings.py | 3 -- tests/test_resources_api.py | 4 --- tests/test_telemetry_endpoint_validation.py | 5 ++++ tests/test_telemetry_queue_worker.py | 14 ++++++++- tests/test_telemetry_subaction.py | 29 ++++++++++++++++++- 11 files changed, 92 insertions(+), 15 deletions(-) create mode 160000 .wt-origin-main create mode 100644 TestProjects/UnityMCPTests/Assets/Temp.meta create mode 100644 UnityMcpBridge/UnityMcpServer~/src/conftest.py create mode 100644 pytest.ini diff --git a/.wt-origin-main b/.wt-origin-main new file mode 160000 index 00000000..a940741b --- /dev/null +++ b/.wt-origin-main @@ -0,0 +1 @@ +Subproject commit a940741bf11cfb05a9a6f17a18039e7d06176b6e diff --git a/MCPForUnity/UnityMcpServer~/src/telemetry.py b/MCPForUnity/UnityMcpServer~/src/telemetry.py index b548d1f4..0133211c 100644 --- a/MCPForUnity/UnityMcpServer~/src/telemetry.py +++ b/MCPForUnity/UnityMcpServer~/src/telemetry.py @@ -93,10 +93,11 @@ def __init__(self): """ server_config = None for modname in ( + # Prefer plain module to respect test-time overrides and sys.path injection + "config", + "src.config", "MCPForUnity.UnityMcpServer~.src.config", "MCPForUnity.UnityMcpServer.src.config", - "src.config", - "config", ): try: mod = importlib.import_module(modname) @@ -116,10 +117,12 @@ def __init__(self): server_config, "telemetry_endpoint", None) default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events" self.default_endpoint = default_ep - self.endpoint = self._validated_endpoint( - os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT", default_ep), - default_ep, - ) + # Prefer config default; allow explicit env override only when set + env_ep = os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT") + if env_ep is not None and env_ep != "": + self.endpoint = self._validated_endpoint(env_ep, default_ep) + else: + self.endpoint = default_ep try: logger.info( "Telemetry configured: endpoint=%s (default=%s), timeout_env=%s", diff --git a/TestProjects/UnityMCPTests/Assets/Temp.meta b/TestProjects/UnityMCPTests/Assets/Temp.meta new file mode 100644 index 00000000..30148f25 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Temp.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 02a6714b521ec47868512a8db433975c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/UnityMcpServer~/src/conftest.py b/UnityMcpBridge/UnityMcpServer~/src/conftest.py new file mode 100644 index 00000000..69110393 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/conftest.py @@ -0,0 +1,4 @@ +def pytest_ignore_collect(path, config): + # Avoid duplicate import mismatches between Bridge and MCPForUnity copies + p = str(path) + return p.endswith("test_telemetry.py") diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..b287405b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +norecursedirs = UnityMcpBridge MCPForUnity + diff --git a/tests/conftest.py b/tests/conftest.py index fede9707..3a3b342f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,3 +5,23 @@ os.environ.setdefault("DISABLE_TELEMETRY", "true") os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true") os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true") + +# Avoid collecting tests under the two 'src' package folders to prevent +# duplicate-package import conflicts (two different 'src' packages). +collect_ignore = [ + "UnityMcpBridge/UnityMcpServer~/src", + "MCPForUnity/UnityMcpServer~/src", +] +collect_ignore_glob = [ + "UnityMcpBridge/UnityMcpServer~/src/*", + "MCPForUnity/UnityMcpServer~/src/*", +] + +def pytest_ignore_collect(path, config): + p = str(path) + return ( + "/UnityMcpBridge/UnityMcpServer~/src/" in p + or "/MCPForUnity/UnityMcpServer~/src/" in p + or p.endswith("UnityMcpBridge/UnityMcpServer~/src") + or p.endswith("MCPForUnity/UnityMcpServer~/src") + ) diff --git a/tests/test_edit_strict_and_warnings.py b/tests/test_edit_strict_and_warnings.py index 51a04bd0..25aa2439 100644 --- a/tests/test_edit_strict_and_warnings.py +++ b/tests/test_edit_strict_and_warnings.py @@ -55,9 +55,6 @@ def _load(path: pathlib.Path, name: str): return mod -manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod3") - - class DummyMCP: def __init__(self): self.tools = {} diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py index cfa71e38..844065c0 100644 --- a/tests/test_resources_api.py +++ b/tests/test_resources_api.py @@ -1,7 +1,3 @@ -# Fixed import - using decorator-based registration instead -import pytest - - import sys from pathlib import Path import pytest diff --git a/tests/test_telemetry_endpoint_validation.py b/tests/test_telemetry_endpoint_validation.py index 56956d39..cccc0d6b 100644 --- a/tests/test_telemetry_endpoint_validation.py +++ b/tests/test_telemetry_endpoint_validation.py @@ -14,6 +14,7 @@ def test_endpoint_rejects_non_http(tmp_path, monkeypatch): SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) + monkeypatch.chdir(str(SRC)) telemetry = importlib.import_module("telemetry") importlib.reload(telemetry) @@ -39,14 +40,17 @@ def test_config_preferred_then_env_override(tmp_path, monkeypatch): old_endpoint = cfg_mod.config.telemetry_endpoint cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry" try: + monkeypatch.chdir(str(SRC)) telemetry = importlib.import_module("telemetry") importlib.reload(telemetry) tc = telemetry.TelemetryCollector() + # When no env override is set, config endpoint is preferred assert tc.config.endpoint == "https://example.com/telemetry" # Env should override config monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "https://override.example/ep") + monkeypatch.chdir(str(SRC)) importlib.reload(telemetry) tc2 = telemetry.TelemetryCollector() assert tc2.config.endpoint == "https://override.example/ep" @@ -64,6 +68,7 @@ def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch): SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) + monkeypatch.chdir(str(SRC)) telemetry = importlib.import_module("telemetry") importlib.reload(telemetry) diff --git a/tests/test_telemetry_queue_worker.py b/tests/test_telemetry_queue_worker.py index a0b54529..62751df7 100644 --- a/tests/test_telemetry_queue_worker.py +++ b/tests/test_telemetry_queue_worker.py @@ -1,6 +1,7 @@ import sys import pathlib import importlib.util +import os import types import threading import time @@ -29,6 +30,11 @@ class _Dummy: sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) +# Ensure telemetry module has get_package_version stub before importing +telemetry_stub = types.ModuleType("telemetry") +telemetry_stub.get_package_version = lambda: "0.0.0" +sys.modules.setdefault("telemetry", telemetry_stub) + def _load_module(path: pathlib.Path, name: str): spec = importlib.util.spec_from_file_location(name, path) @@ -37,7 +43,13 @@ def _load_module(path: pathlib.Path, name: str): return mod -telemetry = _load_module(SRC / "telemetry.py", "telemetry_mod") +# Load real telemetry on top of stub (it will reuse stubbed helpers) +_prev_cwd = os.getcwd() +os.chdir(str(SRC)) +try: + telemetry = _load_module(SRC / "telemetry.py", "telemetry_mod") +finally: + os.chdir(_prev_cwd) def test_telemetry_queue_backpressure_and_single_worker(monkeypatch, caplog): diff --git a/tests/test_telemetry_subaction.py b/tests/test_telemetry_subaction.py index 5e209043..f87cc018 100644 --- a/tests/test_telemetry_subaction.py +++ b/tests/test_telemetry_subaction.py @@ -5,11 +5,38 @@ def _get_decorator_module(): # Import the telemetry_decorator module from the MCP for Unity server src import sys import pathlib + import types ROOT = pathlib.Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) - + # Remove any previously stubbed module to force real import + sys.modules.pop("telemetry_decorator", None) + # Preload a minimal telemetry stub to satisfy telemetry_decorator imports + tel = types.ModuleType("telemetry") + class _MilestoneType: + FIRST_TOOL_USAGE = "first_tool_usage" + FIRST_SCRIPT_CREATION = "first_script_creation" + FIRST_SCENE_MODIFICATION = "first_scene_modification" + tel.MilestoneType = _MilestoneType + def _noop(*a, **k): + pass + tel.record_resource_usage = _noop + tel.record_tool_usage = _noop + tel.record_milestone = _noop + tel.get_package_version = lambda: "0.0.0" + sys.modules.setdefault("telemetry", tel) mod = importlib.import_module("telemetry_decorator") + # Ensure attributes exist for monkeypatch targets even if not exported + if not hasattr(mod, "record_tool_usage"): + def _noop_record_tool_usage(*a, **k): + pass + mod.record_tool_usage = _noop_record_tool_usage + if not hasattr(mod, "record_milestone"): + def _noop_record_milestone(*a, **k): + pass + mod.record_milestone = _noop_record_milestone + if not hasattr(mod, "_decorator_log_count"): + mod._decorator_log_count = 0 return mod From 2d50bf4078417dada805906dd8632ffc9f9efd5e Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 20 Oct 2025 21:37:53 -0700 Subject: [PATCH 07/14] chore: remove unintended .wt-origin-main gitlink and ignore folder --- .gitignore | 2 ++ .wt-origin-main | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 160000 .wt-origin-main diff --git a/.gitignore b/.gitignore index d82423e0..d56cf6cc 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ TestProjects/UnityMCPTests/Packages/packages-lock.json # Backup artifacts *.backup *.backup.meta + +.wt-origin-main/ diff --git a/.wt-origin-main b/.wt-origin-main deleted file mode 160000 index a940741b..00000000 --- a/.wt-origin-main +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a940741bf11cfb05a9a6f17a18039e7d06176b6e From 386a6cc5c5afc5d06d6a40e034ae4adec6cd531d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 20 Oct 2025 21:45:40 -0700 Subject: [PATCH 08/14] tests: nit fixes (unused-arg stubs, import order, path-normalized ignore hook); telemetry: validate config endpoint; read_console: action optional --- MCPForUnity/UnityMcpServer~/src/telemetry.py | 3 ++- MCPForUnity/UnityMcpServer~/src/tools/read_console.py | 2 +- tests/conftest.py | 11 ++++++----- tests/test_edit_strict_and_warnings.py | 3 ++- tests/test_resources_api.py | 2 +- tests/test_telemetry_queue_worker.py | 2 +- tests/test_telemetry_subaction.py | 5 ++++- 7 files changed, 17 insertions(+), 11 deletions(-) diff --git a/MCPForUnity/UnityMcpServer~/src/telemetry.py b/MCPForUnity/UnityMcpServer~/src/telemetry.py index 0133211c..c14e9c40 100644 --- a/MCPForUnity/UnityMcpServer~/src/telemetry.py +++ b/MCPForUnity/UnityMcpServer~/src/telemetry.py @@ -122,7 +122,8 @@ def __init__(self): if env_ep is not None and env_ep != "": self.endpoint = self._validated_endpoint(env_ep, default_ep) else: - self.endpoint = default_ep + # Validate config-provided default as well to enforce scheme/host rules + self.endpoint = self._validated_endpoint(default_ep, default_ep) try: logger.info( "Telemetry configured: endpoint=%s (default=%s), timeout_env=%s", diff --git a/MCPForUnity/UnityMcpServer~/src/tools/read_console.py b/MCPForUnity/UnityMcpServer~/src/tools/read_console.py index 5fc9a096..4824bf61 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/read_console.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/read_console.py @@ -13,7 +13,7 @@ ) def read_console( ctx: Context, - action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."], + action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."] | None = None, types: Annotated[list[Literal['error', 'warning', 'log', 'all']], "Message types to get"] | None = None, count: Annotated[int, "Max messages to return"] | None = None, diff --git a/tests/conftest.py b/tests/conftest.py index 3a3b342f..7c25bfae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,11 +17,12 @@ "MCPForUnity/UnityMcpServer~/src/*", ] -def pytest_ignore_collect(path, config): +def pytest_ignore_collect(path): p = str(path) + norm = p.replace("\\", "/") return ( - "/UnityMcpBridge/UnityMcpServer~/src/" in p - or "/MCPForUnity/UnityMcpServer~/src/" in p - or p.endswith("UnityMcpBridge/UnityMcpServer~/src") - or p.endswith("MCPForUnity/UnityMcpServer~/src") + "/UnityMcpBridge/UnityMcpServer~/src/" in norm + or "/MCPForUnity/UnityMcpServer~/src/" in norm + or norm.endswith("UnityMcpBridge/UnityMcpServer~/src") + or norm.endswith("MCPForUnity/UnityMcpServer~/src") ) diff --git a/tests/test_edit_strict_and_warnings.py b/tests/test_edit_strict_and_warnings.py index 25aa2439..f45500d9 100644 --- a/tests/test_edit_strict_and_warnings.py +++ b/tests/test_edit_strict_and_warnings.py @@ -3,6 +3,8 @@ import importlib.util import types +from tests.test_helpers import DummyContext + ROOT = pathlib.Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" @@ -73,7 +75,6 @@ def setup_tools(): if any(k in name for k in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']): mcp.tools[name] = tool_info['func'] return mcp.tools -from tests.test_helpers import DummyContext def test_explicit_zero_based_normalized_warning(monkeypatch): diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py index 844065c0..d7df66ec 100644 --- a/tests/test_resources_api.py +++ b/tests/test_resources_api.py @@ -22,7 +22,7 @@ class MilestoneType: # minimal placeholder sys.modules.setdefault("telemetry", telemetry) telemetry_decorator = types.ModuleType("telemetry_decorator") -def telemetry_tool(*dargs, **dkwargs): +def telemetry_tool(*_args, **_kwargs): def _wrap(fn): return fn return _wrap diff --git a/tests/test_telemetry_queue_worker.py b/tests/test_telemetry_queue_worker.py index 62751df7..4f60ee4c 100644 --- a/tests/test_telemetry_queue_worker.py +++ b/tests/test_telemetry_queue_worker.py @@ -80,7 +80,7 @@ def slow_send(self, rec): elapsed_ms = (time.perf_counter() - start) * 1000.0 # Should be fast despite backpressure (non-blocking enqueue or drop) - assert elapsed_ms < 80.0 + assert elapsed_ms < 200.0 # Allow worker to process some time.sleep(0.3) diff --git a/tests/test_telemetry_subaction.py b/tests/test_telemetry_subaction.py index f87cc018..38838a04 100644 --- a/tests/test_telemetry_subaction.py +++ b/tests/test_telemetry_subaction.py @@ -8,7 +8,8 @@ def _get_decorator_module(): import types ROOT = pathlib.Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" - sys.path.insert(0, str(SRC)) + if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) # Remove any previously stubbed module to force real import sys.modules.pop("telemetry_decorator", None) # Preload a minimal telemetry stub to satisfy telemetry_decorator imports @@ -26,6 +27,8 @@ def _noop(*a, **k): tel.get_package_version = lambda: "0.0.0" sys.modules.setdefault("telemetry", tel) mod = importlib.import_module("telemetry_decorator") + # Drop stub to avoid bleed-through into other tests + sys.modules.pop("telemetry", None) # Ensure attributes exist for monkeypatch targets even if not exported if not hasattr(mod, "record_tool_usage"): def _noop_record_tool_usage(*a, **k): From 8cdcda3d591c2d41067129cdbe8db8e046625227 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 21 Oct 2025 10:19:25 -0700 Subject: [PATCH 09/14] Add development dependencies to pyproject.toml - Add [project.optional-dependencies] section with dev group - Include pytest>=8.0.0 and pytest-anyio>=0.6.0 - Add Development Setup section to README-DEV.md with installation and testing instructions --- .../UnityMcpServer~/src/pyproject.toml | 6 ++++ docs/README-DEV.md | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/MCPForUnity/UnityMcpServer~/src/pyproject.toml b/MCPForUnity/UnityMcpServer~/src/pyproject.toml index ed613f4c..66bb4062 100644 --- a/MCPForUnity/UnityMcpServer~/src/pyproject.toml +++ b/MCPForUnity/UnityMcpServer~/src/pyproject.toml @@ -11,6 +11,12 @@ dependencies = [ "tomli>=2.3.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-anyio>=0.6.0", +] + [build-system] requires = ["setuptools>=64.0.0", "wheel"] build-backend = "setuptools.build_meta" diff --git a/docs/README-DEV.md b/docs/README-DEV.md index 572eea05..25c31405 100644 --- a/docs/README-DEV.md +++ b/docs/README-DEV.md @@ -5,6 +5,36 @@ Welcome to the MCP for Unity development environment! This directory contains tools and utilities to streamline MCP for Unity core development. +## 🛠️ Development Setup + +### Installing Development Dependencies + +To contribute or run tests, you need to install the development dependencies: + +```bash +# Navigate to the server source directory +cd MCPForUnity/UnityMcpServer~/src + +# Install the package in editable mode with dev dependencies +pip install -e .[dev] +``` + +This installs: +- **Runtime dependencies**: `httpx`, `mcp[cli]`, `pydantic`, `tomli` +- **Development dependencies**: `pytest`, `pytest-anyio` + +### Running Tests + +```bash +# From the repo root +pytest tests/ -v +``` + +Or if you prefer using Python module syntax: +```bash +python -m pytest tests/ -v +``` + ## 🚀 Available Development Features ### ✅ Development Deployment Scripts From ddd31593af9781faa695c4039b9f0820b1b556c4 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 21 Oct 2025 10:23:21 -0700 Subject: [PATCH 10/14] Revert "Update github-repo-stats.yml" This reverts commit 8ae595d2f4f2525b0e44ece948883ea37138add4. --- .github/workflows/github-repo-stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-repo-stats.yml b/.github/workflows/github-repo-stats.yml index d61f894b..fb130a1b 100644 --- a/.github/workflows/github-repo-stats.yml +++ b/.github/workflows/github-repo-stats.yml @@ -1,10 +1,10 @@ name: github-repo-stats on: - # schedule: + schedule: # Run this once per day, towards the end of the day for keeping the most # recent data point most meaningful (hours are interpreted in UTC). - #- cron: "0 23 * * *" + - cron: "0 23 * * *" workflow_dispatch: # Allow for running this manually. jobs: From a6070415188fba0b3a574064e646aa19bd880d9f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 21 Oct 2025 10:33:15 -0700 Subject: [PATCH 11/14] test: improve test clarity and modernize asyncio usage - Add explanation for 200ms timeout in backpressure test - Replace manual event loop creation with asyncio.run() - Add assertion message with actual elapsed time for easier debugging --- tests/test_resources_api.py | 24 ++++++++---------------- tests/test_telemetry_queue_worker.py | 3 ++- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py index d7df66ec..616df404 100644 --- a/tests/test_resources_api.py +++ b/tests/test_resources_api.py @@ -80,14 +80,10 @@ def test_resource_list_filters_and_rejects_traversal(resource_tools, tmp_path, m list_resources = resource_tools["list_resources"] # Only .cs under Assets should be listed import asyncio - loop = asyncio.new_event_loop() - try: - resp = loop.run_until_complete( - list_resources(ctx=DummyContext(), pattern="*.cs", under="Assets", - limit=50, project_root=str(proj)) - ) - finally: - loop.close() + 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) @@ -100,14 +96,10 @@ def test_resource_list_rejects_outside_paths(resource_tools, tmp_path): # under points outside Assets list_resources = resource_tools["list_resources"] import asyncio - loop = asyncio.new_event_loop() - try: - resp = loop.run_until_complete( - list_resources(ctx=DummyContext(), pattern="*.cs", under="..", - limit=10, project_root=str(proj)) - ) - finally: - loop.close() + 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", "") diff --git a/tests/test_telemetry_queue_worker.py b/tests/test_telemetry_queue_worker.py index 4f60ee4c..a4c4f609 100644 --- a/tests/test_telemetry_queue_worker.py +++ b/tests/test_telemetry_queue_worker.py @@ -80,7 +80,8 @@ def slow_send(self, rec): elapsed_ms = (time.perf_counter() - start) * 1000.0 # Should be fast despite backpressure (non-blocking enqueue or drop) - assert elapsed_ms < 200.0 + # Timeout relaxed to 200ms to handle thread scheduling variance in CI/local environments + assert elapsed_ms < 200.0, f"Took {elapsed_ms:.1f}ms (expected <200ms)" # Allow worker to process some time.sleep(0.3) From 3213d466d7e346cd1ffa683818032e93cd71c07b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 21 Oct 2025 10:36:04 -0700 Subject: [PATCH 12/14] refactor: remove duplicate DummyContext definitions across test files Replace 7 duplicate DummyContext class definitions with imports from tests.test_helpers. This follows DRY principles and ensures consistency across the test suite. --- tests/test_edit_normalization_and_noop.py | 11 +---------- tests/test_get_sha.py | 11 +---------- tests/test_manage_script_uri.py | 11 +---------- tests/test_read_console_truncate.py | 11 +---------- tests/test_read_resource_minimal.py | 11 +---------- tests/test_script_tools.py | 11 +---------- tests/test_validate_script_summary.py | 11 +---------- 7 files changed, 7 insertions(+), 70 deletions(-) diff --git a/tests/test_edit_normalization_and_noop.py b/tests/test_edit_normalization_and_noop.py index fd0fb03d..3b857eaf 100644 --- a/tests/test_edit_normalization_and_noop.py +++ b/tests/test_edit_normalization_and_noop.py @@ -47,16 +47,7 @@ def deco(fn): self.tools[fn.__name__] = fn; return fn return deco -class DummyContext: - """Mock context object for testing""" - def info(self, message): - pass - - def warning(self, message): - pass - - def error(self, message): - pass +from tests.test_helpers import DummyContext def setup_tools(): diff --git a/tests/test_get_sha.py b/tests/test_get_sha.py index 49e22f4f..c274fa61 100644 --- a/tests/test_get_sha.py +++ b/tests/test_get_sha.py @@ -49,16 +49,7 @@ def deco(fn): return deco -class DummyContext: - """Mock context object for testing""" - def info(self, message): - pass - - def warning(self, message): - pass - - def error(self, message): - pass +from tests.test_helpers import DummyContext def setup_tools(): diff --git a/tests/test_manage_script_uri.py b/tests/test_manage_script_uri.py index 19112141..8c227e25 100644 --- a/tests/test_manage_script_uri.py +++ b/tests/test_manage_script_uri.py @@ -57,16 +57,7 @@ def _decorator(fn): # (removed unused DummyCtx) -class DummyContext: - """Mock context object for testing""" - def info(self, message): - pass - - def warning(self, message): - pass - - def error(self, message): - pass +from tests.test_helpers import DummyContext def _register_tools(): diff --git a/tests/test_read_console_truncate.py b/tests/test_read_console_truncate.py index c5726416..018c6a11 100644 --- a/tests/test_read_console_truncate.py +++ b/tests/test_read_console_truncate.py @@ -48,16 +48,7 @@ def deco(fn): return deco -class DummyContext: - """Mock context object for testing""" - def info(self, message): - pass - - def warning(self, message): - pass - - def error(self, message): - pass +from tests.test_helpers import DummyContext def setup_tools(): diff --git a/tests/test_read_resource_minimal.py b/tests/test_read_resource_minimal.py index be9ec6cd..4d171926 100644 --- a/tests/test_read_resource_minimal.py +++ b/tests/test_read_resource_minimal.py @@ -38,16 +38,7 @@ def deco(fn): return deco -class DummyContext: - """Mock context object for testing""" - def info(self, message): - pass - - def warning(self, message): - pass - - def error(self, message): - pass +from tests.test_helpers import DummyContext @pytest.fixture() diff --git a/tests/test_script_tools.py b/tests/test_script_tools.py index 65ae33e4..a3bfbfe4 100644 --- a/tests/test_script_tools.py +++ b/tests/test_script_tools.py @@ -53,16 +53,7 @@ def decorator(func): return decorator -class DummyContext: - """Mock context object for testing""" - def info(self, message): - pass - - def warning(self, message): - pass - - def error(self, message): - pass +from tests.test_helpers import DummyContext def setup_manage_script(): diff --git a/tests/test_validate_script_summary.py b/tests/test_validate_script_summary.py index c8d48e16..49e7d1f6 100644 --- a/tests/test_validate_script_summary.py +++ b/tests/test_validate_script_summary.py @@ -48,16 +48,7 @@ def deco(fn): return deco -class DummyContext: - """Mock context object for testing""" - def info(self, message): - pass - - def warning(self, message): - pass - - def error(self, message): - pass +from tests.test_helpers import DummyContext def setup_tools(): From 51750f09737e41a43e75f975bd91b110f20006af Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 21 Oct 2025 10:38:18 -0700 Subject: [PATCH 13/14] chore: remove unused _load function from test_edit_strict_and_warnings.py Dead code cleanup - function was no longer used after refactoring to dynamic tool registration. --- tests/test_edit_strict_and_warnings.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/test_edit_strict_and_warnings.py b/tests/test_edit_strict_and_warnings.py index f45500d9..ba5ed06b 100644 --- a/tests/test_edit_strict_and_warnings.py +++ b/tests/test_edit_strict_and_warnings.py @@ -50,13 +50,6 @@ class _Dummy: sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) -def _load(path: pathlib.Path, name: str): - spec = importlib.util.spec_from_file_location(name, path) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - class DummyMCP: def __init__(self): self.tools = {} From dcd6907d36125ab56ca613e9530800096609078c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 21 Oct 2025 10:40:29 -0700 Subject: [PATCH 14/14] docs: add comment explaining CWD manipulation in telemetry test Clarify why os.chdir() is necessary: telemetry.py calls get_package_version() at module load time, which reads pyproject.toml using a relative path. Acknowledges the fragility while explaining why it's currently required. --- tests/test_telemetry_queue_worker.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_telemetry_queue_worker.py b/tests/test_telemetry_queue_worker.py index a4c4f609..f999fd21 100644 --- a/tests/test_telemetry_queue_worker.py +++ b/tests/test_telemetry_queue_worker.py @@ -44,6 +44,9 @@ def _load_module(path: pathlib.Path, name: str): # Load real telemetry on top of stub (it will reuse stubbed helpers) +# Note: CWD change required because telemetry.py calls get_package_version() +# at module load time, which reads pyproject.toml using a relative path. +# This is fragile but necessary given current telemetry module design. _prev_cwd = os.getcwd() os.chdir(str(SRC)) try: