- Fix server blocking issue during semantic indexing by using FastAPI BackgroundTasks
- Add job progress tracking with real-time status updates
- Add /api/jobs and /api/jobs/{job_id} endpoints for monitoring
- Add JobsDashboard component with live progress bars and status indicators
- Add Jobs tab to MainApp for interactive monitoring
- Maintain backward compatibility with background=false option for sync mode
- Include automatic job cleanup to prevent memory leaks
375 lines
14 KiB
Python
375 lines
14 KiB
Python
"""
|
|
Tests for the Claude Code MCP Server (claude_code_mcp.py).
|
|
|
|
This is the critical bridge between Claude Code and the Claude OS REST API.
|
|
These tests ensure the MCP protocol is correctly implemented.
|
|
"""
|
|
|
|
import pytest
|
|
import json
|
|
from unittest.mock import AsyncMock, patch, MagicMock
|
|
import sys
|
|
import os
|
|
|
|
# Add the project root to the path
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
class TestMCPServerTools:
|
|
"""Test the MCP server tool definitions."""
|
|
|
|
@pytest.fixture
|
|
def mock_server(self):
|
|
"""Create a mock MCP server for testing."""
|
|
with patch.dict(os.environ, {"CLAUDE_OS_API": "http://localhost:8051"}):
|
|
from mcp_server.claude_code_mcp import server, list_tools
|
|
return server, list_tools
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tools_returns_all_tools(self, mock_server):
|
|
"""Test that list_tools returns all expected tools."""
|
|
server, list_tools_func = mock_server
|
|
tools = await list_tools_func()
|
|
|
|
# Should have all the expected tools
|
|
tool_names = [t.name for t in tools]
|
|
|
|
expected_tools = [
|
|
"list_knowledge_bases",
|
|
"create_knowledge_base",
|
|
"search_knowledge_base",
|
|
"get_kb_stats",
|
|
"list_documents",
|
|
"delete_knowledge_base",
|
|
"list_projects",
|
|
"create_project",
|
|
"get_project",
|
|
"index_structural",
|
|
"index_semantic",
|
|
"upload_document",
|
|
"delete_document",
|
|
"get_ollama_models",
|
|
"health_check"
|
|
]
|
|
|
|
for expected in expected_tools:
|
|
assert expected in tool_names, f"Missing tool: {expected}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tools_have_valid_schemas(self, mock_server):
|
|
"""Test that all tools have valid input schemas."""
|
|
server, list_tools_func = mock_server
|
|
tools = await list_tools_func()
|
|
|
|
for tool in tools:
|
|
assert tool.inputSchema is not None, f"Tool {tool.name} has no schema"
|
|
assert "type" in tool.inputSchema, f"Tool {tool.name} schema missing 'type'"
|
|
assert tool.inputSchema["type"] == "object", f"Tool {tool.name} schema should be object"
|
|
assert "properties" in tool.inputSchema, f"Tool {tool.name} schema missing 'properties'"
|
|
assert "required" in tool.inputSchema, f"Tool {tool.name} schema missing 'required'"
|
|
|
|
|
|
class TestMCPServerAPIIntegration:
|
|
"""Test the MCP server's integration with the REST API."""
|
|
|
|
@pytest.fixture
|
|
def mock_httpx(self):
|
|
"""Mock httpx for API calls."""
|
|
with patch("mcp_server.claude_code_mcp.httpx.AsyncClient") as mock:
|
|
yield mock
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_url_builder(self):
|
|
"""Test the API URL builder function."""
|
|
with patch.dict(os.environ, {"CLAUDE_OS_API": "http://test:9000"}):
|
|
# Re-import to pick up new env var
|
|
import importlib
|
|
import mcp_server.claude_code_mcp as mcp_module
|
|
importlib.reload(mcp_module)
|
|
|
|
url = mcp_module.api_url("/api/kb")
|
|
assert url == "http://test:9000/api/kb"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_knowledge_bases_calls_correct_endpoint(self, mock_httpx):
|
|
"""Test that list_knowledge_bases calls the correct API endpoint."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"knowledge_bases": []}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_httpx.return_value = mock_client
|
|
|
|
from mcp_server.claude_code_mcp import _execute_tool
|
|
result = await _execute_tool("list_knowledge_bases", {})
|
|
|
|
mock_client.get.assert_called_once()
|
|
call_args = mock_client.get.call_args
|
|
assert "/api/kb" in str(call_args)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_knowledge_base_calls_correct_endpoint(self, mock_httpx):
|
|
"""Test that search_knowledge_base calls the correct API endpoint."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"answer": "test", "sources": []}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
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=None)
|
|
mock_httpx.return_value = mock_client
|
|
|
|
from mcp_server.claude_code_mcp import _execute_tool
|
|
result = await _execute_tool("search_knowledge_base", {
|
|
"kb_name": "test_kb",
|
|
"query": "test query"
|
|
})
|
|
|
|
mock_client.post.assert_called_once()
|
|
call_args = mock_client.post.call_args
|
|
assert "/api/kb/test_kb/chat" in str(call_args)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_knowledge_base_sends_correct_payload(self, mock_httpx):
|
|
"""Test that create_knowledge_base sends the correct payload."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"id": 1, "name": "new_kb"}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
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=None)
|
|
mock_httpx.return_value = mock_client
|
|
|
|
from mcp_server.claude_code_mcp import _execute_tool
|
|
result = await _execute_tool("create_knowledge_base", {
|
|
"name": "new_kb",
|
|
"kb_type": "documentation",
|
|
"description": "Test KB"
|
|
})
|
|
|
|
mock_client.post.assert_called_once()
|
|
call_args = mock_client.post.call_args
|
|
# Check the JSON payload
|
|
assert call_args.kwargs.get("json", {}).get("name") == "new_kb"
|
|
|
|
|
|
class TestMCPServerErrorHandling:
|
|
"""Test error handling in the MCP server."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_tool_returns_error(self):
|
|
"""Test that unknown tools return an error."""
|
|
from mcp_server.claude_code_mcp import _execute_tool
|
|
result = await _execute_tool("nonexistent_tool", {})
|
|
|
|
assert "error" in result
|
|
assert "Unknown tool" in result["error"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connection_error_handled_gracefully(self):
|
|
"""Test that connection errors are handled gracefully."""
|
|
import httpx
|
|
|
|
with patch("mcp_server.claude_code_mcp.httpx.AsyncClient") as mock_httpx:
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_httpx.return_value = mock_client
|
|
|
|
from mcp_server.claude_code_mcp import call_tool
|
|
result = await call_tool("list_knowledge_bases", {})
|
|
|
|
# Should return a TextContent with error message
|
|
assert len(result) == 1
|
|
text = result[0].text
|
|
error_data = json.loads(text)
|
|
assert "error" in error_data
|
|
assert "Cannot connect" in error_data["error"] or "Connection" in str(error_data)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_error_handled_gracefully(self):
|
|
"""Test that HTTP errors are handled gracefully."""
|
|
import httpx
|
|
|
|
with patch("mcp_server.claude_code_mcp.httpx.AsyncClient") as mock_httpx:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 500
|
|
mock_response.text = "Internal Server Error"
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(side_effect=httpx.HTTPStatusError(
|
|
"Server error",
|
|
request=MagicMock(),
|
|
response=mock_response
|
|
))
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_httpx.return_value = mock_client
|
|
|
|
from mcp_server.claude_code_mcp import call_tool
|
|
result = await call_tool("list_knowledge_bases", {})
|
|
|
|
# Should return a TextContent with error message
|
|
assert len(result) == 1
|
|
text = result[0].text
|
|
error_data = json.loads(text)
|
|
assert "error" in error_data
|
|
|
|
|
|
class TestMCPServerHealthCheck:
|
|
"""Test the health check functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_success(self):
|
|
"""Test health check when server is healthy."""
|
|
with patch("mcp_server.claude_code_mcp.api_get") as mock_get:
|
|
mock_get.return_value = {"knowledge_bases": [{"id": 1}, {"id": 2}]}
|
|
|
|
from mcp_server.claude_code_mcp import _execute_tool
|
|
result = await _execute_tool("health_check", {})
|
|
|
|
assert result["status"] == "healthy"
|
|
assert result["kb_count"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_failure(self):
|
|
"""Test health check when server is unhealthy."""
|
|
with patch("mcp_server.claude_code_mcp.api_get") as mock_get:
|
|
mock_get.side_effect = Exception("Connection failed")
|
|
|
|
from mcp_server.claude_code_mcp import _execute_tool
|
|
result = await _execute_tool("health_check", {})
|
|
|
|
assert result["status"] == "unhealthy"
|
|
assert "error" in result
|
|
|
|
|
|
class TestMCPServerDocumentOperations:
|
|
"""Test document-related operations."""
|
|
|
|
@pytest.fixture
|
|
def mock_httpx(self):
|
|
"""Mock httpx for API calls."""
|
|
with patch("mcp_server.claude_code_mcp.httpx.AsyncClient") as mock:
|
|
yield mock
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_document_sends_correct_payload(self, mock_httpx):
|
|
"""Test that upload_document sends the correct payload."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"success": True}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
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=None)
|
|
mock_httpx.return_value = mock_client
|
|
|
|
from mcp_server.claude_code_mcp import _execute_tool
|
|
result = await _execute_tool("upload_document", {
|
|
"kb_name": "test_kb",
|
|
"content": "# Test Document\n\nThis is a test.",
|
|
"filename": "test.md",
|
|
"title": "Test Document",
|
|
"tags": ["test", "example"]
|
|
})
|
|
|
|
mock_client.post.assert_called_once()
|
|
call_args = mock_client.post.call_args
|
|
payload = call_args.kwargs.get("json", {})
|
|
|
|
assert payload.get("content") == "# Test Document\n\nThis is a test."
|
|
assert payload.get("filename") == "test.md"
|
|
assert payload.get("metadata", {}).get("title") == "Test Document"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_document_calls_correct_endpoint(self, mock_httpx):
|
|
"""Test that delete_document calls the correct endpoint."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"success": True}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.delete = AsyncMock(return_value=mock_response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_httpx.return_value = mock_client
|
|
|
|
from mcp_server.claude_code_mcp import _execute_tool
|
|
result = await _execute_tool("delete_document", {
|
|
"kb_name": "test_kb",
|
|
"filename": "old_doc.md"
|
|
})
|
|
|
|
mock_client.delete.assert_called_once()
|
|
call_args = mock_client.delete.call_args
|
|
assert "/api/kb/test_kb/documents/old_doc.md" in str(call_args)
|
|
|
|
|
|
class TestMCPServerIndexingOperations:
|
|
"""Test indexing-related operations."""
|
|
|
|
@pytest.fixture
|
|
def mock_httpx(self):
|
|
"""Mock httpx for API calls."""
|
|
with patch("mcp_server.claude_code_mcp.httpx.AsyncClient") as mock:
|
|
yield mock
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_index_structural_sends_path(self, mock_httpx):
|
|
"""Test that index_structural sends the correct path."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"indexed": 50}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
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=None)
|
|
mock_httpx.return_value = mock_client
|
|
|
|
from mcp_server.claude_code_mcp import _execute_tool
|
|
result = await _execute_tool("index_structural", {
|
|
"kb_name": "code_kb",
|
|
"path": "/path/to/project"
|
|
})
|
|
|
|
mock_client.post.assert_called_once()
|
|
call_args = mock_client.post.call_args
|
|
payload = call_args.kwargs.get("json", {})
|
|
assert payload.get("path") == "/path/to/project"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_index_semantic_calls_correct_endpoint(self, mock_httpx):
|
|
"""Test that index_semantic calls the correct endpoint."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"job_id": "semantic-docs_kb-abc123", "status": "queued"}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
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=None)
|
|
mock_httpx.return_value = mock_client
|
|
|
|
from mcp_server.claude_code_mcp import _execute_tool
|
|
result = await _execute_tool("index_semantic", {
|
|
"kb_name": "docs_kb",
|
|
"project_path": "/path/to/project"
|
|
})
|
|
|
|
mock_client.post.assert_called_once()
|
|
call_args = mock_client.post.call_args
|
|
assert "/api/kb/docs_kb/index-semantic" in str(call_args)
|
|
# Verify the payload includes required fields
|
|
payload = call_args[1].get("json", {})
|
|
assert payload.get("project_path") == "/path/to/project"
|
|
assert payload.get("background") == True
|