"""Tests for the MCP Text Editor Server."""
import json
from pathlib import Path
from typing import List
import pytest
from mcp.server import stdio
from mcp.types import TextContent, Tool
from pytest_mock import MockerFixture
from mcp_text_editor.server import (
GetTextFileContentsHandler,
app,
append_file_handler,
call_tool,
create_file_handler,
delete_contents_handler,
get_contents_handler,
insert_file_handler,
list_tools,
main,
patch_file_handler,
)
@pytest.mark.asyncio
async def test_list_tools():
"""Test tool listing."""
tools: List[Tool] = await list_tools()
assert len(tools) == 6
# Verify GetTextFileContents tool
get_contents_tool = next(
(tool for tool in tools if tool.name == "get_text_file_contents"), None
)
assert get_contents_tool is not None
assert "file" in get_contents_tool.description.lower()
assert "contents" in get_contents_tool.description.lower()
@pytest.mark.asyncio
async def test_get_contents_empty_files():
"""Test get_contents handler with empty files list."""
arguments = {"files": []}
result = await get_contents_handler.run_tool(arguments)
assert len(result) == 1
assert result[0].type == "text"
# Should return empty JSON object
assert json.loads(result[0].text) == {}
@pytest.mark.asyncio
async def test_unknown_tool_handler():
"""Test handling of unknown tool name."""
with pytest.raises(ValueError) as excinfo:
await call_tool("unknown_tool", {})
assert "Unknown tool: unknown_tool" in str(excinfo.value)
@pytest.mark.asyncio
async def test_get_contents_handler(test_file):
"""Test GetTextFileContents handler."""
args = {"files": [{"file_path": test_file, "ranges": [{"start": 1, "end": 3}]}]}
result = await get_contents_handler.run_tool(args)
assert len(result) == 1
assert isinstance(result[0], TextContent)
content = json.loads(result[0].text)
assert test_file in content
range_result = content[test_file]["ranges"][0]
assert "content" in range_result
assert "start" in range_result
assert "end" in range_result
assert "file_hash" in content[test_file]
assert "total_lines" in range_result
assert "content_size" in range_result
@pytest.mark.asyncio
async def test_get_contents_handler_invalid_file(test_file):
"""Test GetTextFileContents handler with invalid file."""
# Convert relative path to absolute
nonexistent_path = str(Path("nonexistent.txt").absolute())
args = {"files": [{"file_path": nonexistent_path, "ranges": [{"start": 1}]}]}
with pytest.raises(RuntimeError) as exc_info:
await get_contents_handler.run_tool(args)
assert "File not found" in str(exc_info.value)
@pytest.mark.asyncio
async def test_call_tool_get_contents(test_file):
"""Test call_tool with GetTextFileContents."""
args = {"files": [{"file_path": test_file, "ranges": [{"start": 1, "end": 3}]}]}
result = await call_tool("get_text_file_contents", args)
assert len(result) == 1
assert isinstance(result[0], TextContent)
content = json.loads(result[0].text)
assert test_file in content
range_result = content[test_file]["ranges"][0]
assert "content" in range_result
assert "start" in range_result
assert "end" in range_result
assert "file_hash" in content[test_file]
assert "total_lines" in range_result
assert "content_size" in range_result
@pytest.mark.asyncio
async def test_call_tool_unknown():
"""Test call_tool with unknown tool."""
with pytest.raises(ValueError) as exc_info:
await call_tool("UnknownTool", {})
assert "Unknown tool" in str(exc_info.value)
@pytest.mark.asyncio
async def test_call_tool_error_handling():
"""Test call_tool error handling."""
# Test with invalid arguments
with pytest.raises(RuntimeError) as exc_info:
await call_tool("get_text_file_contents", {"invalid": "args"})
assert "Missing required argument" in str(exc_info.value)
# Convert relative path to absolute
nonexistent_path = str(Path("nonexistent.txt").absolute())
with pytest.raises(RuntimeError) as exc_info:
await call_tool(
"get_text_file_contents",
{"files": [{"file_path": nonexistent_path, "ranges": [{"start": 1}]}]},
)
assert "File not found" in str(exc_info.value)
@pytest.mark.asyncio
async def test_get_contents_handler_legacy_missing_args():
"""Test GetTextFileContents handler with legacy single file request missing arguments."""
with pytest.raises(RuntimeError) as exc_info:
await get_contents_handler.run_tool({})
assert "Missing required argument: 'files'" in str(exc_info.value)
@pytest.mark.asyncio
async def test_main_stdio_server_error(mocker: MockerFixture):
"""Test main function with stdio_server error."""
# Mock the stdio_server to raise an exception
mock_stdio = mocker.patch.object(stdio, "stdio_server")
mock_stdio.side_effect = Exception("Stdio server error")
with pytest.raises(Exception) as exc_info:
await main()
assert "Stdio server error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_main_run_error(mocker: MockerFixture):
"""Test main function with app.run error."""
# Mock the stdio_server context manager
mock_stdio = mocker.patch.object(stdio, "stdio_server")
mock_context = mocker.MagicMock()
mock_context.__aenter__.return_value = (mocker.MagicMock(), mocker.MagicMock())
mock_stdio.return_value = mock_context
# Mock app.run to raise an exception
mock_run = mocker.patch.object(app, "run")
mock_run.side_effect = Exception("App run error")
with pytest.raises(Exception) as exc_info:
await main()
assert "App run error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_get_contents_relative_path():
"""Test GetTextFileContents with relative path."""
handler = GetTextFileContentsHandler()
with pytest.raises(RuntimeError, match="File path must be absolute:.*"):
await handler.run_tool(
{
"files": [
{"file_path": "relative/path/file.txt", "ranges": [{"start": 1}]}
]
}
)
@pytest.mark.asyncio
async def test_get_contents_absolute_path():
"""Test GetTextFileContents with absolute path."""
handler = GetTextFileContentsHandler()
abs_path = str(Path("/absolute/path/file.txt").absolute())
# Define mock as async function
async def mock_read_multiple_ranges(*args, **kwargs):
return []
# Set up mock
handler.editor.read_multiple_ranges = mock_read_multiple_ranges
result = await handler.run_tool(
{"files": [{"file_path": abs_path, "ranges": [{"start": 1}]}]}
)
assert isinstance(result[0], TextContent)
@pytest.mark.asyncio
async def test_call_tool_general_exception():
"""Test call_tool with a general exception."""
# Patch get_contents_handler.run_tool to raise a general exception
original_run_tool = get_contents_handler.run_tool
async def mock_run_tool(args):
raise Exception("Unexpected error")
try:
get_contents_handler.run_tool = mock_run_tool
with pytest.raises(RuntimeError) as exc_info:
await call_tool("get_text_file_contents", {"files": []})
assert "Error executing command: Unexpected error" in str(exc_info.value)
finally:
# Restore original method
get_contents_handler.run_tool = original_run_tool
@pytest.mark.asyncio
async def test_call_tool_all_handlers(mocker: MockerFixture):
"""Test call_tool with all handlers."""
# Mock run_tool for each handler
handlers = [
create_file_handler,
append_file_handler,
delete_contents_handler,
insert_file_handler,
patch_file_handler,
]
# Setup mocks for all handlers
async def mock_run_tool(args):
return [TextContent(text="mocked response", type="text")]
for handler in handlers:
mock = mocker.patch.object(handler, "run_tool")
mock.side_effect = mock_run_tool
# Test each handler
for handler in handlers:
result = await call_tool(handler.name, {"test": "args"})
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "mocked response"