""" Tests for stdio-mode custom tool discovery (GitHub issue #837). Verifies that: 1. sync_tool_visibility_from_unity registers custom tools when extended metadata is present 2. Custom tools are skipped gracefully when metadata is missing (old Unity package) 3. Reconnection flag triggers a background re-sync """ import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_unity_response(tools, include_extended=True): """Build a fake get_tool_states response from Unity.""" tool_list = [] for t in tools: entry = { "name": t["name"], "group": t.get("group", "core"), "enabled": t.get("enabled", True), } if include_extended: entry.update({ "description": t.get("description", f"Tool: {t['name']}"), "auto_register": t.get("auto_register", True), "is_built_in": t.get("is_built_in", True), "structured_output": t.get("structured_output", False), "requires_polling": t.get("requires_polling", False), "poll_action": t.get("poll_action", "status"), "max_poll_seconds": t.get("max_poll_seconds", 0), "parameters": t.get("parameters", []), }) tool_list.append(entry) return { "data": { "tools": tool_list, "groups": [], } } BUILTIN_TOOL = { "name": "manage_gameobject", "group": "core", "is_built_in": True, "description": "Manage GameObjects in the scene.", } CUSTOM_TOOL = { "name": "test_ping", "group": "core", "is_built_in": False, "description": "Simple test tool that returns a pong.", "parameters": [ {"name": "message", "description": "Message to echo", "type": "string", "required": False, "default_value": "pong"}, ], } # --------------------------------------------------------------------------- # sync_tool_visibility_from_unity — custom tool registration # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_sync_registers_custom_tools(): """Custom (non-built-in) tools should be registered via CustomToolService.""" response = _make_unity_response([BUILTIN_TOOL, CUSTOM_TOOL]) mock_service = MagicMock() mock_service.register_global_tools = MagicMock() with patch( "transport.legacy.unity_connection.async_send_command_with_retry", new_callable=AsyncMock, return_value=response, ), patch( "transport.plugin_hub.PluginHub._sync_server_tool_visibility", ), patch( "transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed", new_callable=AsyncMock, ), patch( "services.custom_tool_service.CustomToolService.get_instance", return_value=mock_service, ): from services.tools import sync_tool_visibility_from_unity result = await sync_tool_visibility_from_unity(notify=False) assert result["synced"] is True assert result["custom_tool_count"] == 1 # Verify register_global_tools was called with the custom tool mock_service.register_global_tools.assert_called_once() registered = mock_service.register_global_tools.call_args[0][0] assert len(registered) == 1 assert registered[0].name == "test_ping" assert registered[0].description == "Simple test tool that returns a pong." assert len(registered[0].parameters) == 1 assert registered[0].parameters[0].name == "message" @pytest.mark.asyncio async def test_sync_skips_builtin_tools(): """Built-in tools should NOT be passed to register_global_tools.""" response = _make_unity_response([BUILTIN_TOOL]) with patch( "transport.legacy.unity_connection.async_send_command_with_retry", new_callable=AsyncMock, return_value=response, ), patch( "transport.plugin_hub.PluginHub._sync_server_tool_visibility", ), patch( "transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed", new_callable=AsyncMock, ), patch( "services.custom_tool_service.CustomToolService.get_instance", ) as mock_get_instance: from services.tools import sync_tool_visibility_from_unity result = await sync_tool_visibility_from_unity(notify=False) assert result["synced"] is True assert result["custom_tool_count"] == 0 # No custom tools → register_global_tools should NOT be called mock_get_instance.assert_not_called() @pytest.mark.asyncio async def test_sync_skips_when_no_extended_metadata(): """When Unity returns old-format data (no is_built_in), skip custom tool registration.""" response = _make_unity_response([BUILTIN_TOOL, CUSTOM_TOOL], include_extended=False) with patch( "transport.legacy.unity_connection.async_send_command_with_retry", new_callable=AsyncMock, return_value=response, ), patch( "transport.plugin_hub.PluginHub._sync_server_tool_visibility", ), patch( "transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed", new_callable=AsyncMock, ), patch( "services.custom_tool_service.CustomToolService.get_instance", ) as mock_get_instance: from services.tools import sync_tool_visibility_from_unity result = await sync_tool_visibility_from_unity(notify=False) assert result["synced"] is True assert result["custom_tool_count"] == 0 mock_get_instance.assert_not_called() @pytest.mark.asyncio async def test_sync_handles_custom_tool_service_not_initialized(): """If CustomToolService isn't initialized yet, skip gracefully (no crash).""" response = _make_unity_response([CUSTOM_TOOL]) with patch( "transport.legacy.unity_connection.async_send_command_with_retry", new_callable=AsyncMock, return_value=response, ), patch( "transport.plugin_hub.PluginHub._sync_server_tool_visibility", ), patch( "transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed", new_callable=AsyncMock, ), patch( "services.custom_tool_service.CustomToolService.get_instance", side_effect=RuntimeError("not initialized"), ): from services.tools import sync_tool_visibility_from_unity result = await sync_tool_visibility_from_unity(notify=False) # Should succeed overall even though custom tool registration failed assert result["synced"] is True assert result["custom_tool_count"] == 0 # --------------------------------------------------------------------------- # Reconnection re-sync trigger # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_reconnection_flag_triggers_resync(): """After reconnection, async_send_command_with_retry should schedule a re-sync.""" mock_conn = MagicMock() mock_conn._needs_tool_resync = True mock_conn.instance_id = None mock_pool = MagicMock() mock_pool.get_connection.return_value = mock_conn with patch( "transport.legacy.unity_connection.send_command_with_retry", return_value={"success": True, "message": "ok"}, ), patch( "transport.legacy.unity_connection.get_unity_connection_pool", return_value=mock_pool, ), patch( "transport.legacy.unity_connection._resync_tools_after_reconnect", new_callable=AsyncMock, ) as mock_resync: from transport.legacy.unity_connection import async_send_command_with_retry result = await async_send_command_with_retry("manage_gameobject", {"action": "list"}) # ensure_future schedules on the event loop; give it a tick to run await asyncio.sleep(0) assert result["success"] is True # Flag should be cleared assert mock_conn._needs_tool_resync is False # Re-sync should have been scheduled mock_resync.assert_awaited_once_with(None) @pytest.mark.asyncio async def test_no_resync_for_get_tool_states(): """get_tool_states itself should NOT trigger re-sync (avoids recursion).""" mock_conn = MagicMock() mock_conn._needs_tool_resync = True mock_pool = MagicMock() mock_pool.get_connection.return_value = mock_conn with patch( "transport.legacy.unity_connection.send_command_with_retry", return_value={"data": {"tools": []}}, ), patch( "transport.legacy.unity_connection.get_unity_connection_pool", return_value=mock_pool, ), patch( "transport.legacy.unity_connection._resync_tools_after_reconnect", new_callable=AsyncMock, ) as mock_resync: from transport.legacy.unity_connection import async_send_command_with_retry await async_send_command_with_retry("get_tool_states", {}) # Flag should be cleared, but no re-sync task should be scheduled assert mock_conn._needs_tool_resync is False mock_resync.assert_not_awaited() @pytest.mark.asyncio async def test_no_resync_when_not_reconnected(): """When _needs_tool_resync is False, no re-sync should be scheduled.""" mock_conn = MagicMock() mock_conn._needs_tool_resync = False mock_pool = MagicMock() mock_pool.get_connection.return_value = mock_conn with patch( "transport.legacy.unity_connection.send_command_with_retry", return_value={"success": True}, ), patch( "transport.legacy.unity_connection.get_unity_connection_pool", return_value=mock_pool, ), patch( "transport.legacy.unity_connection._resync_tools_after_reconnect", new_callable=AsyncMock, ) as mock_resync: from transport.legacy.unity_connection import async_send_command_with_retry await async_send_command_with_retry("manage_gameobject", {"action": "list"}) mock_resync.assert_not_awaited()