MCP Filesystem Server

by safurrier
Verified
MIT License
5
  • Apple
  • Linux
"""Unit tests for the server module. These tests verify that the MCP server correctly exposes file operations to clients and handles errors properly, focusing on behavior rather than implementation details. """ import os import json import tempfile from pathlib import Path from typing import Dict, Any, List, Optional from unittest.mock import AsyncMock, MagicMock, patch, call import pytest from fastmcp import Context from mcp_filesystem.server import ( mcp, get_components, get_allowed_dirs, read_file, write_file, list_directory, move_file, edit_file_at_line, grep_files, read_file_lines, get_file_info, ) from mcp_filesystem.security import PathValidator from mcp_filesystem.operations import FileOperations from mcp_filesystem.grep import GrepTools, GrepResult, GrepMatch class MockContext: """Mock Context for MCP testing.""" def __init__(self, **kwargs): """Initialize with optional attributes.""" for key, value in kwargs.items(): setattr(self, key, value) @pytest.fixture def mock_context(): """Create a mock MCP context.""" return MockContext() @pytest.fixture def mock_components(): """Create mock components for testing.""" # Create mock validator, operations, and grep tools validator = MagicMock(spec=PathValidator) operations = MagicMock(spec=FileOperations) grep = MagicMock(spec=GrepTools) advanced = MagicMock() # Configure common behavior validator.get_allowed_dirs.return_value = ["/test"] # Create the components dict components = { "validator": validator, "operations": operations, "grep": grep, "advanced": advanced, "allowed_dirs": ["/test"], } return components @pytest.fixture def test_filesystem(): """Create a temporary filesystem for testing. This fixture provides a real filesystem for tests, avoiding excessive mocking of file operations. """ with tempfile.TemporaryDirectory() as temp_dir: base_dir = Path(temp_dir) # Set up standard file structure test_file = base_dir / "test.txt" test_file.write_text("This is a test file\nWith multiple lines\nFor testing") # Create a subdirectory subdir = base_dir / "subdir" subdir.mkdir() (subdir / "subfile.txt").write_text("File in subdirectory") # Create a file with search terms grep_file = base_dir / "searchable.txt" grep_file.write_text( "This line has a search term\n" "This line doesn't match\n" "Another line with search term\n" ) yield base_dir @pytest.mark.asyncio class TestServerCore: """Tests for server core functionality.""" @patch('sys.argv', ['server.py']) # Override command line args for test async def test_get_allowed_dirs_from_environment(self): """Verify that allowed directories are correctly retrieved from environment. This test focuses on behavior: given environment variables, does get_allowed_dirs return the expected directories? """ # Arrange test_dirs = "/test/dir1:/test/dir2" # Act - use patch to temporarily set environment variable with patch.dict(os.environ, {"MCP_ALLOWED_DIRS": test_dirs}), \ patch('sys.argv', ['server.py']): # Ensure no command-line args result = get_allowed_dirs() # Assert - focus on behavior assert "/test/dir1" in result assert "/test/dir2" in result assert all(d.startswith("/test/") for d in result) @patch('sys.argv', ['server.py']) # Override command line args for test async def test_get_allowed_dirs_default_to_cwd(self): """Verify that allowed directories default to current directory when none specified.""" # Arrange - temporarily clear the environment variable test_dir = "/current/dir" with patch.dict(os.environ, {"MCP_ALLOWED_DIRS": ""}), \ patch('os.getcwd', return_value=test_dir), \ patch('sys.argv', ['server.py']): # Ensure no command-line args # Act result = get_allowed_dirs() # Assert - only care if the current directory is included assert test_dir in result async def test_get_components_provides_required_components(self): """Verify that get_components returns all required components. This test focuses on the behavior (returning necessary components) rather than implementation details (like caching). """ # Act - clear cache and get new components from mcp_filesystem.server import _components_cache _components_cache.clear() # Override allowed dirs to avoid real file access with patch('mcp_filesystem.server.get_allowed_dirs', return_value=["/test"]): components = get_components() # Assert - focus on behavior, not implementation assert "validator" in components assert "operations" in components assert "grep" in components assert "advanced" in components assert "allowed_dirs" in components # Clean up for other tests _components_cache.clear() @pytest.mark.asyncio class TestServerTools: """Tests for server tool functions.""" async def test_read_file_returns_content(self, mock_context, mock_components): """Verify read_file tool returns file content.""" # Arrange test_path = "/test/file.txt" expected_content = "File content" # Configure operations mock mock_components["operations"].read_file = AsyncMock(return_value=expected_content) # Act with patch('mcp_filesystem.server.get_components', return_value=mock_components): result = await read_file(test_path, mock_context) # Assert - focus on expected behavior assert result == expected_content async def test_read_file_handles_errors(self, mock_context, mock_components): """Verify read_file tool handles exceptions gracefully.""" # Arrange test_path = "/test/invalid.txt" # Configure operations mock to raise exception mock_components["operations"].read_file = AsyncMock( side_effect=ValueError("Invalid path") ) # Act with patch('mcp_filesystem.server.get_components', return_value=mock_components): result = await read_file(test_path, mock_context) # Assert - check behavior assert "Error" in result assert "path" in result.lower() or "Invalid" in result async def test_write_file_with_real_filesystem(self, mock_context, test_filesystem): """Test write_file using a real filesystem to verify behavior. This tests actual behavior rather than implementation details. """ # Arrange test_path = str(test_filesystem / "new_file.txt") test_content = "New test content" # Create real components with the test filesystem validator = PathValidator([str(test_filesystem)]) operations = FileOperations(validator) components = { "validator": validator, "operations": operations, "allowed_dirs": [str(test_filesystem)] } # Act - write to a real file with patch('mcp_filesystem.server.get_components', return_value=components): result = await write_file(test_path, test_content, mock_context) # Assert - verify the file was actually written assert Path(test_path).exists() assert Path(test_path).read_text() == test_content assert "success" in result.lower() async def test_list_directory_with_real_filesystem(self, mock_context, test_filesystem): """Test list_directory with real filesystem to verify behavior.""" # Arrange validator = PathValidator([str(test_filesystem)]) operations = FileOperations(validator) components = { "validator": validator, "operations": operations, "allowed_dirs": [str(test_filesystem)] } # Act - list real directory with patch('mcp_filesystem.server.get_components', return_value=components): # Test text format text_result = await list_directory(str(test_filesystem), mock_context, format="text") # Test JSON format json_result = await list_directory(str(test_filesystem), mock_context, format="json") # Assert - focus on behavior # Text format should contain expected files assert "test.txt" in text_result assert "subdir" in text_result assert "searchable.txt" in text_result # JSON format should be parseable and contain expected files parsed = json.loads(json_result) assert isinstance(parsed, list) file_names = [entry["name"] for entry in parsed] assert "test.txt" in file_names assert "subdir" in file_names assert "searchable.txt" in file_names async def test_grep_files_finds_matches(self, mock_context, test_filesystem): """Test grep_files with real filesystem to verify behavior.""" # Arrange - create real components validator = PathValidator([str(test_filesystem)]) operations = FileOperations(validator) grep = GrepTools(validator) components = { "validator": validator, "operations": operations, "grep": grep, "allowed_dirs": [str(test_filesystem)] } # The test file contains two lines with "search term" test_path = str(test_filesystem / "searchable.txt") test_pattern = "search term" # Act - search with real grep functionality with patch('mcp_filesystem.server.get_components', return_value=components): result = await grep_files(test_path, test_pattern, mock_context) # Assert - focus on behavior: did it find the expected matches? assert "search term" in result assert "2 matches" in result.lower() or "matches: 2" in result.lower() async def test_edit_file_at_line_applies_edits(self, mock_context, test_filesystem): """Test edit_file_at_line with real filesystem to verify behavior.""" # Arrange - create a file with known content test_path = str(test_filesystem / "edit_test.txt") Path(test_path).write_text("Line 1\nLine 2\nLine 3\n") # Create real components validator = PathValidator([str(test_filesystem)]) operations = FileOperations(validator) components = { "validator": validator, "operations": operations, "allowed_dirs": [str(test_filesystem)] } # Define line edits to apply line_edits = [ {"line_number": 2, "action": "replace", "content": "Modified Line 2"} ] # Act - edit the file with patch('mcp_filesystem.server.get_components', return_value=components): result = await edit_file_at_line(test_path, line_edits, mock_context) # Assert - verify the file was actually edited file_content = Path(test_path).read_text() expected_content = "Line 1\nModified Line 2\nLine 3\n" assert file_content == expected_content assert "edits" in result.lower() assert "applied" in result.lower() async def test_read_file_lines_returns_specific_lines(self, mock_context, test_filesystem): """Test read_file_lines with real filesystem to verify behavior.""" # Arrange - create a file with multiple lines test_path = str(test_filesystem / "lines_test.txt") test_content = "\n".join([f"Line {i}" for i in range(1, 11)]) Path(test_path).write_text(test_content) # Create real components validator = PathValidator([str(test_filesystem)]) operations = FileOperations(validator) components = { "validator": validator, "operations": operations, "allowed_dirs": [str(test_filesystem)] } # Act - read specific lines with patch('mcp_filesystem.server.get_components', return_value=components): result = await read_file_lines( test_path, mock_context, offset=2, limit=3 ) # Assert - verify correct lines are returned # Should include lines 3, 4, 5 (offset 2, limit 3) assert "Line 3" in result assert "Line 4" in result assert "Line 5" in result assert "Line 1" not in result assert "Line 6" not in result async def test_get_file_info_returns_metadata(self, mock_context, test_filesystem): """Test get_file_info with real filesystem to verify behavior.""" # Arrange - create file with known content test_path = str(test_filesystem / "info_test.txt") test_content = "This is a test file for get_file_info" Path(test_path).write_text(test_content) # Create real components validator = PathValidator([str(test_filesystem)]) operations = FileOperations(validator) components = { "validator": validator, "operations": operations, "allowed_dirs": [str(test_filesystem)] } # Act - get file info with patch('mcp_filesystem.server.get_components', return_value=components): text_result = await get_file_info(test_path, mock_context, format="text") json_result = await get_file_info(test_path, mock_context, format="json") # Assert - verify metadata is correct # Text format should contain basic info assert "File" in text_result assert Path(test_path).name in text_result assert "Size" in text_result # JSON format should be valid and contain expected fields parsed = json.loads(json_result) assert parsed["name"] == Path(test_path).name assert parsed["size"] == len(test_content) assert parsed["is_file"] is True assert "created" in parsed assert "modified" in parsed if __name__ == "__main__": pytest.main(["-xvs", __file__])