"""Tests for the MCP bridge module (Qwen3 via Ollama).""" from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from timmy.mcp_bridge import ( BridgeResult, MCPBridge, MCPToolDef, _build_gitea_tools, _build_shell_tool, _mcp_schema_to_ollama_tool, ) # --------------------------------------------------------------------------- # _mcp_schema_to_ollama_tool # --------------------------------------------------------------------------- def test_schema_to_ollama_tool_basic(): """Converts an MCPToolDef to Ollama tool format.""" tool = MCPToolDef( name="test_tool", description="A test tool", parameters={ "type": "object", "properties": {"arg1": {"type": "string"}}, "required": ["arg1"], }, handler=AsyncMock(), ) result = _mcp_schema_to_ollama_tool(tool) assert result["type"] == "function" assert result["function"]["name"] == "test_tool" assert result["function"]["description"] == "A test tool" assert result["function"]["parameters"]["type"] == "object" assert "arg1" in result["function"]["parameters"]["properties"] def test_schema_to_ollama_tool_wraps_bare_params(): """Wraps bare parameter dicts in an object type.""" tool = MCPToolDef( name="bare", description="Bare params", parameters={"x": {"type": "integer"}}, handler=AsyncMock(), ) result = _mcp_schema_to_ollama_tool(tool) params = result["function"]["parameters"] assert params["type"] == "object" assert "x" in params["properties"] assert "x" in params["required"] # --------------------------------------------------------------------------- # _build_shell_tool # --------------------------------------------------------------------------- def test_build_shell_tool_returns_def(): """Shell tool builder returns an MCPToolDef.""" tool = _build_shell_tool() assert tool is not None assert tool.name == "shell_exec" assert "command" in tool.parameters["properties"] def test_build_shell_tool_graceful_on_import_error(): """Shell tool returns None when infrastructure is unavailable.""" with patch.dict("sys.modules", {"infrastructure.hands.shell": None}): # Force re-import failure — but _build_shell_tool catches it with patch( "timmy.mcp_bridge._build_shell_tool", wraps=_build_shell_tool, ): # The real function should handle import errors tool = _build_shell_tool() # May return tool if import cache succeeds, or None if not # Just verify it doesn't raise assert tool is None or isinstance(tool, MCPToolDef) # --------------------------------------------------------------------------- # _build_gitea_tools # --------------------------------------------------------------------------- def test_gitea_tools_empty_when_disabled(): """Gitea tools returns empty list when disabled.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.gitea_enabled = False mock_settings.gitea_token = "" result = _build_gitea_tools() assert result == [] def test_gitea_tools_empty_when_no_token(): """Gitea tools returns empty list when no token.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.gitea_enabled = True mock_settings.gitea_token = "" result = _build_gitea_tools() assert result == [] def test_gitea_tools_returns_three_tools(): """Gitea tools returns list_issues, create_issue, read_issue.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.gitea_url = "http://localhost:3000" mock_settings.gitea_repo = "owner/repo" result = _build_gitea_tools() assert len(result) == 3 names = {t.name for t in result} assert names == {"list_issues", "create_issue", "read_issue"} # --------------------------------------------------------------------------- # MCPBridge.__init__ # --------------------------------------------------------------------------- def test_bridge_init_default(): """MCPBridge initialises with default settings.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.ollama_model = "qwen3:14b" mock_settings.normalized_ollama_url = "http://localhost:11434" mock_settings.gitea_enabled = False mock_settings.gitea_token = "" bridge = MCPBridge(include_gitea=False, include_shell=False) assert bridge.model == "qwen3:14b" assert bridge.tool_names == [] def test_bridge_init_with_extra_tools(): """MCPBridge accepts extra tool definitions.""" custom = MCPToolDef( name="custom_tool", description="Custom", parameters={"type": "object", "properties": {}, "required": []}, handler=AsyncMock(), ) with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.ollama_model = "qwen3:14b" mock_settings.normalized_ollama_url = "http://localhost:11434" mock_settings.gitea_enabled = False mock_settings.gitea_token = "" bridge = MCPBridge( include_gitea=False, include_shell=False, extra_tools=[custom], ) assert "custom_tool" in bridge.tool_names # --------------------------------------------------------------------------- # MCPBridge.run — tool-call loop # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_bridge_run_simple_response(): """Bridge returns model content when no tool calls are made.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.ollama_model = "qwen3:14b" mock_settings.normalized_ollama_url = "http://localhost:11434" mock_settings.ollama_num_ctx = 4096 mock_settings.mcp_bridge_timeout = 60 mock_settings.gitea_enabled = False mock_settings.gitea_token = "" bridge = MCPBridge(include_gitea=False, include_shell=False) mock_resp = MagicMock() mock_resp.json.return_value = {"message": {"role": "assistant", "content": "Hello!"}} mock_resp.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_resp) mock_client.aclose = AsyncMock() bridge._client = mock_client result = await bridge.run("Hi") assert result.content == "Hello!" assert result.rounds == 1 assert result.tool_calls_made == [] assert result.error == "" @pytest.mark.asyncio async def test_bridge_run_with_tool_call(): """Bridge executes tool calls and returns final response.""" handler = AsyncMock(return_value="tool result data") tool = MCPToolDef( name="my_tool", description="Test", parameters={"type": "object", "properties": {}, "required": []}, handler=handler, ) with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.ollama_model = "qwen3:14b" mock_settings.normalized_ollama_url = "http://localhost:11434" mock_settings.ollama_num_ctx = 0 mock_settings.mcp_bridge_timeout = 60 mock_settings.gitea_enabled = False mock_settings.gitea_token = "" bridge = MCPBridge( include_gitea=False, include_shell=False, extra_tools=[tool], ) # Round 1: model requests tool call tool_call_resp = MagicMock() tool_call_resp.json.return_value = { "message": { "role": "assistant", "content": "", "tool_calls": [ { "function": { "name": "my_tool", "arguments": {}, } } ], } } tool_call_resp.raise_for_status = MagicMock() # Round 2: model returns final text final_resp = MagicMock() final_resp.json.return_value = {"message": {"role": "assistant", "content": "Done with tools!"}} final_resp.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=[tool_call_resp, final_resp]) mock_client.aclose = AsyncMock() bridge._client = mock_client result = await bridge.run("Do something") assert result.content == "Done with tools!" assert result.rounds == 2 assert len(result.tool_calls_made) == 1 assert result.tool_calls_made[0]["tool"] == "my_tool" handler.assert_awaited_once() @pytest.mark.asyncio async def test_bridge_run_unknown_tool(): """Bridge handles calls to unknown tools gracefully.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.ollama_model = "qwen3:14b" mock_settings.normalized_ollama_url = "http://localhost:11434" mock_settings.ollama_num_ctx = 0 mock_settings.mcp_bridge_timeout = 60 mock_settings.gitea_enabled = False mock_settings.gitea_token = "" bridge = MCPBridge(include_gitea=False, include_shell=False) # Model calls a tool that doesn't exist tool_call_resp = MagicMock() tool_call_resp.json.return_value = { "message": { "role": "assistant", "content": "", "tool_calls": [{"function": {"name": "nonexistent", "arguments": {}}}], } } tool_call_resp.raise_for_status = MagicMock() final_resp = MagicMock() final_resp.json.return_value = {"message": {"role": "assistant", "content": "OK"}} final_resp.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=[tool_call_resp, final_resp]) mock_client.aclose = AsyncMock() bridge._client = mock_client result = await bridge.run("test") assert len(result.tool_calls_made) == 1 assert "unknown tool" in result.tool_calls_made[0]["result"] @pytest.mark.asyncio async def test_bridge_run_max_rounds(): """Bridge stops after max_rounds and returns error.""" handler = AsyncMock(return_value="result") tool = MCPToolDef( name="loop_tool", description="Loops forever", parameters={"type": "object", "properties": {}, "required": []}, handler=handler, ) with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.ollama_model = "qwen3:14b" mock_settings.normalized_ollama_url = "http://localhost:11434" mock_settings.ollama_num_ctx = 0 mock_settings.mcp_bridge_timeout = 60 mock_settings.gitea_enabled = False mock_settings.gitea_token = "" bridge = MCPBridge( include_gitea=False, include_shell=False, extra_tools=[tool], max_rounds=2, ) # Always return tool calls (never a final response) tool_call_resp = MagicMock() tool_call_resp.json.return_value = { "message": { "role": "assistant", "content": "", "tool_calls": [{"function": {"name": "loop_tool", "arguments": {}}}], } } tool_call_resp.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=tool_call_resp) mock_client.aclose = AsyncMock() bridge._client = mock_client result = await bridge.run("loop") assert "max tool-call rounds" in result.content assert "Exceeded" in result.error assert result.rounds == 2 @pytest.mark.asyncio async def test_bridge_run_connection_error(): """Bridge handles Ollama connection errors gracefully.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.ollama_model = "qwen3:14b" mock_settings.normalized_ollama_url = "http://localhost:11434" mock_settings.ollama_num_ctx = 0 mock_settings.mcp_bridge_timeout = 60 mock_settings.gitea_enabled = False mock_settings.gitea_token = "" bridge = MCPBridge(include_gitea=False, include_shell=False) mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused")) mock_client.aclose = AsyncMock() bridge._client = mock_client result = await bridge.run("test") assert result.error assert "connection" in result.error.lower() assert result.content == "" @pytest.mark.asyncio async def test_bridge_run_http_error(): """Bridge handles Ollama HTTP errors gracefully.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.ollama_model = "qwen3:14b" mock_settings.normalized_ollama_url = "http://localhost:11434" mock_settings.ollama_num_ctx = 0 mock_settings.mcp_bridge_timeout = 60 mock_settings.gitea_enabled = False mock_settings.gitea_token = "" bridge = MCPBridge(include_gitea=False, include_shell=False) mock_response = MagicMock() mock_response.status_code = 500 mock_client = AsyncMock() mock_client.post = AsyncMock( side_effect=httpx.HTTPStatusError( "Server Error", request=MagicMock(), response=mock_response, ) ) mock_client.aclose = AsyncMock() bridge._client = mock_client result = await bridge.run("test") assert result.error assert "500" in result.error @pytest.mark.asyncio async def test_bridge_run_without_context_manager(): """Bridge returns error when used without async context manager.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.ollama_model = "qwen3:14b" mock_settings.normalized_ollama_url = "http://localhost:11434" mock_settings.gitea_enabled = False mock_settings.gitea_token = "" bridge = MCPBridge(include_gitea=False, include_shell=False) result = await bridge.run("test") assert result.error assert "context manager" in result.error.lower() # --------------------------------------------------------------------------- # MCPBridge.status # --------------------------------------------------------------------------- def test_bridge_status(): """Bridge status returns model and tool info.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.ollama_model = "qwen3:14b" mock_settings.normalized_ollama_url = "http://localhost:11434" mock_settings.gitea_enabled = False mock_settings.gitea_token = "" bridge = MCPBridge(include_gitea=False, include_shell=False) status = bridge.status() assert status["model"] == "qwen3:14b" assert status["connected"] is False assert isinstance(status["tools"], list) # --------------------------------------------------------------------------- # MCPBridge context manager # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_bridge_context_manager(): """Bridge opens and closes httpx client via async context manager.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.ollama_model = "qwen3:14b" mock_settings.normalized_ollama_url = "http://localhost:11434" mock_settings.mcp_bridge_timeout = 60 mock_settings.gitea_enabled = False mock_settings.gitea_token = "" bridge = MCPBridge(include_gitea=False, include_shell=False) assert bridge._client is None async with bridge: assert bridge._client is not None assert bridge._client is None # --------------------------------------------------------------------------- # Gitea tool handlers (integration-style, mocked HTTP) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_gitea_list_issues_handler(): """list_issues handler calls Gitea API and formats results.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.gitea_url = "http://localhost:3000" mock_settings.gitea_repo = "owner/repo" tools = _build_gitea_tools() list_tool = next(t for t in tools if t.name == "list_issues") mock_resp = MagicMock() mock_resp.json.return_value = [ {"number": 1, "title": "Bug one", "labels": [{"name": "bug"}]}, {"number": 2, "title": "Feature two", "labels": []}, ] mock_resp.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get = AsyncMock(return_value=mock_resp) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with patch("timmy.mcp_bridge.httpx.AsyncClient", return_value=mock_client): result = await list_tool.handler(state="open", limit=10) assert "#1: Bug one [bug]" in result assert "#2: Feature two" in result @pytest.mark.asyncio async def test_gitea_create_issue_handler(): """create_issue handler calls Gitea API and returns confirmation.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.gitea_url = "http://localhost:3000" mock_settings.gitea_repo = "owner/repo" tools = _build_gitea_tools() create_tool = next(t for t in tools if t.name == "create_issue") mock_resp = MagicMock() mock_resp.json.return_value = {"number": 42, "title": "New bug"} mock_resp.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_resp) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with patch("timmy.mcp_bridge.httpx.AsyncClient", return_value=mock_client): result = await create_tool.handler(title="New bug", body="Description") assert "#42" in result assert "New bug" in result @pytest.mark.asyncio async def test_gitea_create_issue_requires_title(): """create_issue handler returns error when title is missing.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.gitea_url = "http://localhost:3000" mock_settings.gitea_repo = "owner/repo" tools = _build_gitea_tools() create_tool = next(t for t in tools if t.name == "create_issue") result = await create_tool.handler() assert "required" in result.lower() @pytest.mark.asyncio async def test_gitea_read_issue_handler(): """read_issue handler calls Gitea API and formats result.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.gitea_url = "http://localhost:3000" mock_settings.gitea_repo = "owner/repo" tools = _build_gitea_tools() read_tool = next(t for t in tools if t.name == "read_issue") mock_resp = MagicMock() mock_resp.json.return_value = { "number": 5, "title": "Test issue", "state": "open", "body": "Issue body text", "labels": [{"name": "enhancement"}], } mock_resp.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get = AsyncMock(return_value=mock_resp) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with patch("timmy.mcp_bridge.httpx.AsyncClient", return_value=mock_client): result = await read_tool.handler(number=5) assert "#5" in result assert "Test issue" in result assert "open" in result assert "enhancement" in result @pytest.mark.asyncio async def test_gitea_read_issue_requires_number(): """read_issue handler returns error when number is missing.""" with patch("timmy.mcp_bridge.settings") as mock_settings: mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok123" mock_settings.gitea_url = "http://localhost:3000" mock_settings.gitea_repo = "owner/repo" tools = _build_gitea_tools() read_tool = next(t for t in tools if t.name == "read_issue") result = await read_tool.handler() assert "required" in result.lower() # --------------------------------------------------------------------------- # BridgeResult dataclass # --------------------------------------------------------------------------- def test_bridge_result_defaults(): """BridgeResult has sensible defaults.""" r = BridgeResult(content="hello") assert r.content == "hello" assert r.tool_calls_made == [] assert r.rounds == 0 assert r.latency_ms == 0.0 assert r.model == "" assert r.error == ""