"""Tests for the MCP tools module (factories + issue bridge).""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from timmy.mcp_tools import ( _bridge_to_work_order, _build_issue_args, _build_issue_body, _category_from_labels, _generate_avatar_image, _parse_command, close_mcp_sessions, create_filesystem_mcp_tools, create_gitea_issue_via_mcp, create_gitea_mcp_tools, update_gitea_avatar, ) # --------------------------------------------------------------------------- # _parse_command # --------------------------------------------------------------------------- def test_parse_command_splits_correctly(): """_parse_command splits a command string into executable and args.""" with patch("timmy.mcp_tools.shutil.which", return_value="/usr/local/bin/gitea-mcp"): exe, args = _parse_command("gitea-mcp -t stdio") assert exe == "/usr/local/bin/gitea-mcp" assert args == ["-t", "stdio"] def test_parse_command_expands_tilde(): """_parse_command expands ~/.""" with patch("timmy.mcp_tools.shutil.which", return_value=None): exe, args = _parse_command("~/go/bin/gitea-mcp -t stdio") assert "/go/bin/gitea-mcp" in exe assert "~" not in exe assert args == ["-t", "stdio"] def test_parse_command_preserves_absolute_path(): """_parse_command preserves an absolute path without calling which.""" exe, args = _parse_command("/usr/local/bin/gitea-mcp -t stdio") assert exe == "/usr/local/bin/gitea-mcp" assert args == ["-t", "stdio"] # --------------------------------------------------------------------------- # create_gitea_mcp_tools # --------------------------------------------------------------------------- def test_gitea_mcp_returns_none_when_disabled(): """Gitea MCP factory returns None when gitea_enabled=False.""" with patch("timmy.mcp_tools.settings") as mock_settings: mock_settings.gitea_enabled = False mock_settings.gitea_token = "some-token" result = create_gitea_mcp_tools() assert result is None def test_gitea_mcp_returns_none_when_no_token(): """Gitea MCP factory returns None when gitea_token is empty.""" with patch("timmy.mcp_tools.settings") as mock_settings: mock_settings.gitea_enabled = True mock_settings.gitea_token = "" result = create_gitea_mcp_tools() assert result is None def test_gitea_mcp_returns_tools_when_configured(): """Gitea MCP factory returns an MCPTools instance using server_params.""" mock_mcp = MagicMock() mock_params = MagicMock() with ( patch("timmy.mcp_tools.settings") as mock_settings, patch("agno.tools.mcp.MCPTools", return_value=mock_mcp) as mock_cls, patch("timmy.mcp_tools._gitea_server_params", return_value=mock_params), ): mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.mcp_timeout = 15 result = create_gitea_mcp_tools() assert result is mock_mcp mock_cls.assert_called_once() call_kwargs = mock_cls.call_args[1] assert call_kwargs["server_params"] is mock_params assert "command" not in call_kwargs assert "issue_write" in call_kwargs["include_tools"] assert "pull_request_write" in call_kwargs["include_tools"] def test_gitea_mcp_graceful_on_import_error(): """Gitea MCP factory returns None if agno.tools.mcp isn't available.""" with ( patch("timmy.mcp_tools.settings") as mock_settings, patch.dict("sys.modules", {"agno.tools.mcp": None}), ): mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" # This should gracefully return None (import will fail) result = create_gitea_mcp_tools() assert result is None # --------------------------------------------------------------------------- # create_filesystem_mcp_tools # --------------------------------------------------------------------------- def test_filesystem_mcp_returns_tools(): """Filesystem MCP factory returns an MCPTools instance using server_params.""" mock_mcp = MagicMock() mock_params_cls = MagicMock() with ( patch("timmy.mcp_tools.settings") as mock_settings, patch("agno.tools.mcp.MCPTools", return_value=mock_mcp) as mock_cls, patch("mcp.client.stdio.StdioServerParameters", mock_params_cls), patch("timmy.mcp_tools.shutil.which", return_value="/usr/local/bin/npx"), ): mock_settings.mcp_filesystem_command = "npx -y @modelcontextprotocol/server-filesystem" mock_settings.repo_root = "/home/user/project" mock_settings.mcp_timeout = 15 result = create_filesystem_mcp_tools() assert result is mock_mcp call_kwargs = mock_cls.call_args[1] assert "server_params" in call_kwargs assert "read_file" in call_kwargs["include_tools"] # Verify StdioServerParameters was called with repo_root as an arg params_kwargs = mock_params_cls.call_args[1] assert "/home/user/project" in params_kwargs["args"] # --------------------------------------------------------------------------- # _build_issue_body / _build_issue_args / _category_from_labels # --------------------------------------------------------------------------- def test_build_issue_body_appends_signature(): """_build_issue_body appends the auto-filing signature.""" result = _build_issue_body("Some description") assert result.startswith("Some description\n\n") assert "Auto-filed by Timmy" in result def test_build_issue_body_empty(): """_build_issue_body handles empty body.""" result = _build_issue_body("") assert result.startswith("---\n") def test_build_issue_args(): """_build_issue_args returns correct MCP arguments.""" with patch("timmy.mcp_tools.settings") as mock_settings: mock_settings.gitea_repo = "owner/repo" result = _build_issue_args("Title", "Body") assert result == { "method": "create", "owner": "owner", "repo": "repo", "title": "Title", "body": "Body", } def test_category_from_labels_bug(): """_category_from_labels returns 'bug' when labels contain bug.""" assert _category_from_labels("bug, enhancement") == "bug" def test_category_from_labels_default(): """_category_from_labels returns 'suggestion' by default.""" assert _category_from_labels("enhancement") == "suggestion" assert _category_from_labels("") == "suggestion" # --------------------------------------------------------------------------- # create_gitea_issue_via_mcp # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_issue_via_mcp_returns_not_configured(): """Issue creation returns message when Gitea is not configured.""" with patch("timmy.mcp_tools.settings") as mock_settings: mock_settings.gitea_enabled = False mock_settings.gitea_token = "" result = await create_gitea_issue_via_mcp("Test", "Body") assert "not configured" in result @pytest.mark.asyncio async def test_issue_via_mcp_calls_tool(): """Issue creation calls session.call_tool with correct arguments.""" import timmy.mcp_tools as mcp_mod # Mock the inner MCP session (tools.session.call_tool) mock_inner_session = MagicMock() mock_inner_session.call_tool = AsyncMock(return_value="Issue #42 created") mock_tools = MagicMock() mock_tools.connect = AsyncMock() mock_tools.session = mock_inner_session mock_tools._connected = False mock_params = MagicMock() with ( patch("timmy.mcp_tools.settings") as mock_settings, patch("agno.tools.mcp.MCPTools", return_value=mock_tools), patch("timmy.mcp_tools._gitea_server_params", return_value=mock_params), ): mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.gitea_repo = "owner/repo" mock_settings.gitea_url = "http://localhost:3000" mock_settings.mcp_timeout = 15 mock_settings.repo_root = "/tmp/test" # Reset module-level cache mcp_mod._issue_session = None result = await create_gitea_issue_via_mcp("Bug title", "Bug body", "bug") assert "Bug title" in result mock_tools.connect.assert_awaited_once() # Verify it calls session.call_tool (not tools.call_tool) mock_inner_session.call_tool.assert_awaited_once() call_args = mock_inner_session.call_tool.call_args assert call_args[0][0] == "issue_write" assert call_args[1]["arguments"]["method"] == "create" assert call_args[1]["arguments"]["owner"] == "owner" assert call_args[1]["arguments"]["repo"] == "repo" # Clean up mcp_mod._issue_session = None @pytest.mark.asyncio async def test_issue_via_mcp_graceful_failure(): """Issue creation returns error string on MCP failure.""" import timmy.mcp_tools as mcp_mod mock_tools = MagicMock() mock_tools.connect = AsyncMock(side_effect=ConnectionError("no process")) mock_tools._connected = False mock_params = MagicMock() with ( patch("timmy.mcp_tools.settings") as mock_settings, patch("agno.tools.mcp.MCPTools", return_value=mock_tools), patch("timmy.mcp_tools._gitea_server_params", return_value=mock_params), ): mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.gitea_repo = "owner/repo" mock_settings.gitea_url = "http://localhost:3000" mock_settings.mcp_timeout = 15 mock_settings.repo_root = "/tmp/test" mcp_mod._issue_session = None result = await create_gitea_issue_via_mcp("Test", "Body") assert "Failed" in result mcp_mod._issue_session = None # --------------------------------------------------------------------------- # _bridge_to_work_order # --------------------------------------------------------------------------- def test_bridge_to_work_order(tmp_path): """Work order bridge creates a record in SQLite.""" import sqlite3 with patch("timmy.mcp_tools.settings") as mock_settings: mock_settings.repo_root = str(tmp_path) _bridge_to_work_order("Test title", "Test body", "bug") db_path = tmp_path / "data" / "work_orders.db" assert db_path.exists() conn = sqlite3.connect(str(db_path)) rows = conn.execute("SELECT title, category FROM work_orders").fetchall() conn.close() assert len(rows) == 1 assert rows[0][0] == "Test title" assert rows[0][1] == "bug" # --------------------------------------------------------------------------- # close_mcp_sessions # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_close_mcp_sessions(): """close_mcp_sessions closes the cached session.""" import timmy.mcp_tools as mcp_mod mock_session = MagicMock() mock_session.close = AsyncMock() mcp_mod._issue_session = mock_session await close_mcp_sessions() mock_session.close.assert_awaited_once() assert mcp_mod._issue_session is None @pytest.mark.asyncio async def test_close_mcp_sessions_noop_when_none(): """close_mcp_sessions is a no-op when no session exists.""" import timmy.mcp_tools as mcp_mod mcp_mod._issue_session = None await close_mcp_sessions() # Should not raise assert mcp_mod._issue_session is None # --------------------------------------------------------------------------- # Tool safety integration # --------------------------------------------------------------------------- def test_mcp_tools_classified_in_safety(): """MCP tool names are correctly classified in tool_safety.""" from timmy.tool_safety import DANGEROUS_TOOLS, SAFE_TOOLS, requires_confirmation # Gitea MCP tools should be safe assert "issue_write" in SAFE_TOOLS assert "list_issues" in SAFE_TOOLS assert "pull_request_write" in SAFE_TOOLS # Filesystem read-only MCP tools should be safe assert "list_directory" in SAFE_TOOLS assert "search_files" in SAFE_TOOLS # write_file is dangerous (filesystem MCP) assert "write_file" in DANGEROUS_TOOLS # Verify requires_confirmation logic assert not requires_confirmation("issue_write") assert not requires_confirmation("list_directory") assert requires_confirmation("write_file") # --------------------------------------------------------------------------- # update_gitea_avatar # --------------------------------------------------------------------------- def test_generate_avatar_image_returns_png(): """_generate_avatar_image returns valid PNG bytes.""" pytest.importorskip("PIL") data = _generate_avatar_image() assert isinstance(data, bytes) assert len(data) > 0 # PNG magic bytes assert data[:4] == b"\x89PNG" @pytest.mark.asyncio async def test_update_avatar_not_configured(): """update_gitea_avatar returns message when Gitea is disabled.""" with patch("timmy.mcp_tools.settings") as mock_settings: mock_settings.gitea_enabled = False mock_settings.gitea_token = "" result = await update_gitea_avatar() assert "not configured" in result @pytest.mark.asyncio async def test_update_avatar_success(): """update_gitea_avatar uploads avatar and returns success.""" import sys import timmy.mcp_tools as mcp_mod mock_response = MagicMock() mock_response.status_code = 204 mock_response.text = "" mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) # Ensure PIL import check passes even if Pillow isn't installed pil_stub = MagicMock() with ( patch("timmy.mcp_tools.settings") as mock_settings, patch.object(mcp_mod.httpx, "AsyncClient", return_value=mock_client), patch("timmy.mcp_tools._generate_avatar_image", return_value=b"\x89PNG fake"), patch.dict(sys.modules, {"PIL": pil_stub, "PIL.Image": pil_stub}), ): mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.gitea_url = "http://localhost:3000" result = await update_gitea_avatar() assert "successfully" in result mock_client.post.assert_awaited_once() call_args = mock_client.post.call_args assert "/api/v1/user/avatar" in call_args[0][0] @pytest.mark.asyncio async def test_update_avatar_api_failure(): """update_gitea_avatar handles HTTP error gracefully.""" import sys import timmy.mcp_tools as mcp_mod mock_response = MagicMock() mock_response.status_code = 400 mock_response.text = "bad request" mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) pil_stub = MagicMock() with ( patch("timmy.mcp_tools.settings") as mock_settings, patch.object(mcp_mod.httpx, "AsyncClient", return_value=mock_client), patch("timmy.mcp_tools._generate_avatar_image", return_value=b"\x89PNG fake"), patch.dict(sys.modules, {"PIL": pil_stub, "PIL.Image": pil_stub}), ): mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.gitea_url = "http://localhost:3000" result = await update_gitea_avatar() assert "failed" in result.lower() assert "400" in result @pytest.mark.asyncio async def test_update_avatar_connection_error(): """update_gitea_avatar handles connection errors gracefully.""" import sys import timmy.mcp_tools as mcp_mod mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=mcp_mod.httpx.ConnectError("refused")) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) pil_stub = MagicMock() with ( patch("timmy.mcp_tools.settings") as mock_settings, patch.object(mcp_mod.httpx, "AsyncClient", return_value=mock_client), patch("timmy.mcp_tools._generate_avatar_image", return_value=b"\x89PNG fake"), patch.dict(sys.modules, {"PIL": pil_stub, "PIL.Image": pil_stub}), ): mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.gitea_url = "http://localhost:3000" result = await update_gitea_avatar() assert "connect" in result.lower()