claude-os/tests/test_mcp_server.py
Bob Roberts 61a9ad8f00 Add MCP server, tests, and updated docs
- CLAUDE.md: Added identity, session management, full documentation
  - install.sh: Auto-configures settings.json for users
  - claude_code_mcp.py: True MCP server with stdio transport (15 tools)
  - test_mcp_server.py: 15 tests covering tools, API, errors

  366 tests passing. Ready for the 100 stars
2025-12-05 20:29:51 -06:00

370 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 = {"embedded": 100}
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"
})
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
assert "/api/kb/docs_kb/index-semantic" in str(call_args)