diff --git a/Makefile b/Makefile index 41d3efd..9ef9044 100644 --- a/Makefile +++ b/Makefile @@ -2,27 +2,27 @@ .DEFAULT_GOAL := all test: - pytest + uv run pytest install: uv sync --all-extras coverage: - pytest --cov=mcp_text_editor --cov-report=term-missing + uv run pytest --cov=mcp_text_editor --cov-report=term-missing format: - black src tests - isort src tests - ruff check --fix src tests + uv run black src tests + uv run isort src tests + uv run ruff check --fix src tests lint: - black --check src tests - isort --check src tests - ruff check src tests + uv run black --check src tests + uv run isort --check src tests + uv run ruff check src tests typecheck: - mypy src tests + uv run mypy src tests # Run all checks required before pushing check: lint typecheck diff --git a/README.md b/README.md index 5b07bc2..5fbd927 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,10 @@ [![codecov](https://codecov.io/gh/tumf/mcp-text-editor/branch/main/graph/badge.svg?token=52D51U0ZUR)](https://codecov.io/gh/tumf/mcp-text-editor) [![smithery badge](https://smithery.ai/badge/mcp-text-editor)](https://smithery.ai/server/mcp-text-editor) +[![Glama MCP Server](https://glama.ai/mcp/servers/k44dnvso10/badge)](https://glama.ai/mcp/servers/k44dnvso10) A Model Context Protocol (MCP) server that provides line-oriented text file editing capabilities through a standardized API. Optimized for LLM tools with efficient partial file access to minimize token usage. -mcp-text-editor MCP server - ## Quick Start for Claude.app Users To use this editor with Claude.app, add the following configuration to your prompt: @@ -18,13 +17,12 @@ code ~/Library/Application\ Support/Claude/claude_desktop_config.json ```json { "mcpServers": { - "text-editor": { "command": "uvx", "args": [ "mcp-text-editor" ] - }, + } } } ``` @@ -40,7 +38,8 @@ MCP Text Editor Server is designed to facilitate safe and efficient line-based t - Optimized for LLM tool integration - Safe concurrent editing with hash-based validation - Atomic multi-file operations -- Robust error handling and recovery mechanisms +- Robust error handling with custom error types +- Comprehensive encoding support (utf-8, shift_jis, latin1, etc.) ## Features @@ -83,8 +82,20 @@ source .venv/bin/activate # On Windows: .venv\Scripts\activate uv pip install -e ".[dev]" ``` +## Requirements + +- Python 3.13+ +- POSIX-compliant operating system (Linux, macOS, etc.) or Windows +- File system permissions for read/write operations + ## Installation +### Run via uvx + +```bash +uvx mcp-text-editor +``` + ### Installing via Smithery To install Text Editor Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/mcp-text-editor): @@ -94,14 +105,26 @@ npx -y @smithery/cli install mcp-text-editor --client claude ``` ### Manual Installation + +1. Install Python 3.13+ + ```bash -pip install -e . +pyenv install 3.13.0 +pyenv local 3.13.0 ``` -For development: +2. Install uv (recommended) or pip ```bash -pip install -e ".[dev]" +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +3. Create virtual environment and install dependencies + +```bash +uv venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +uv pip install -e ".[dev]" ``` ## Usage @@ -114,7 +137,7 @@ python -m mcp_text_editor ### MCP Tools -The server provides two main tools: +The server provides several tools for text file manipulation: #### get_text_file_contents @@ -126,7 +149,8 @@ Get the contents of one or more text files with line range specification. { "file_path": "path/to/file.txt", "line_start": 1, - "line_end": 10 + "line_end": 10, + "encoding": "utf-8" // Optional, defaults to utf-8 } ``` @@ -140,7 +164,8 @@ Get the contents of one or more text files with line range specification. "ranges": [ {"start": 1, "end": 10}, {"start": 20, "end": 30} - ] + ], + "encoding": "shift_jis" // Optional, defaults to utf-8 }, { "file_path": "file2.txt", @@ -178,16 +203,16 @@ Parameters: "file1.txt": [ { "content": "Lines 1-10 content", - "start_line": 1, - "end_line": 10, + "start": 1, + "end": 10, "hash": "sha256-hash-1", "total_lines": 50, "content_size": 512 }, { "content": "Lines 20-30 content", - "start_line": 20, - "end_line": 30, + "start": 20, + "end": 30, "hash": "sha256-hash-2", "total_lines": 50, "content_size": 512 @@ -196,8 +221,8 @@ Parameters: "file2.txt": [ { "content": "Lines 5-15 content", - "start_line": 5, - "end_line": 15, + "start": 5, + "end": 15, "hash": "sha256-hash-3", "total_lines": 30, "content_size": 256 @@ -206,9 +231,9 @@ Parameters: } ``` -#### edit_text_file_contents +#### patch_text_file_contents -Edit text file contents with conflict detection. Supports editing multiple files in a single operation. +Apply patches to text files with robust error handling and conflict detection. Supports editing multiple files in a single operation. **Request Format:** @@ -216,29 +241,21 @@ Edit text file contents with conflict detection. Supports editing multiple files { "files": [ { - "path": "file1.txt", + "file_path": "file1.txt", "hash": "sha256-hash-from-get-contents", + "encoding": "utf-8", // Optional, defaults to utf-8 "patches": [ { - "line_start": 5, - "line_end": 8, + "start": 5, + "end": 8, + "range_hash": "sha256-hash-of-content-being-replaced", "contents": "New content for lines 5-8\n" }, { - "line_start": 15, - "line_end": 15, - "contents": "Single line replacement\n" - } - ] - }, - { - "path": "file2.txt", - "hash": "sha256-hash-from-get-contents", - "patches": [ - { - "line_start": 1, - "line_end": 3, - "contents": "Replace first three lines\n" + "start": 15, + "end": null, // null means end of file + "range_hash": "sha256-hash-of-content-being-replaced", + "contents": "Content to append\n" } ] } @@ -247,11 +264,11 @@ Edit text file contents with conflict detection. Supports editing multiple files ``` Important Notes: -1. Always get the current hash using get_text_file_contents before editing +1. Always get the current hash and range_hash using get_text_file_contents before editing 2. Patches are applied from bottom to top to handle line number shifts correctly 3. Patches must not overlap within the same file 4. Line numbers are 1-based -5. If original content ends with newline, ensure patch content also ends with newline +5. `end: null` can be used to append content to the end of file 6. File encoding must match the encoding used in get_text_file_contents **Success Response:** @@ -261,30 +278,31 @@ Important Notes: "file1.txt": { "result": "ok", "hash": "sha256-hash-of-new-contents" - }, - "file2.txt": { - "result": "ok", - "hash": "sha256-hash-of-new-contents" } } ``` -**Error Response:** +**Error Response with Hints:** ```json { "file1.txt": { "result": "error", - "reason": "File not found", - "hash": null - }, - "file2.txt": { + "reason": "Content hash mismatch", + "suggestion": "get", // Suggests using get_text_file_contents + "hint": "Please run get_text_file_contents first to get current content and hashes" + } +} +``` + "result": "error", "reason": "Content hash mismatch - file was modified", "hash": "current-hash", "content": "Current file content" + } } + ``` ### Common Usage Pattern @@ -360,17 +378,24 @@ The server handles various error cases: - Check file and directory permissions - Ensure the server process has necessary read/write access -2. Hash Mismatch Errors +2. Hash Mismatch and Range Hash Errors - The file was modified by another process - - Fetch latest content and retry the operation + - Content being replaced has changed + - Run get_text_file_contents to get fresh hashes + +3. Encoding Issues + - Verify file encoding matches the specified encoding + - Use utf-8 for new files + - Check for BOM markers in files -3. Connection Issues +4. Connection Issues - Verify the server is running and accessible - Check network configuration and firewall settings -4. Performance Issues +5. Performance Issues - Consider using smaller line ranges for large files - Monitor system resources (memory, disk space) + - Use appropriate encoding for file type ## Development @@ -378,8 +403,8 @@ The server handles various error cases: 1. Clone the repository 2. Create and activate a Python virtual environment -3. Install development dependencies: `pip install -e ".[dev]"` -4. Run tests: `pytest` +3. Install development dependencies: `uv pip install -e ".[dev]"` +4. Run tests: `make all` ### Code Quality Tools diff --git a/pyproject.toml b/pyproject.toml index 3a7586f..5a53e90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ ] dependencies = [ "asyncio>=3.4.3", - "mcp>=1.1.2", + "mcp>=1.2.0", "chardet>=5.2.0", ] requires-python = ">=3.13" @@ -38,7 +38,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.pytest.ini_options] -asyncio_mode = "strict" +asyncio_mode = "auto" testpaths = "tests" asyncio_default_fixture_loop_scope = "function" pythonpath = ["src"] diff --git a/src/mcp_text_editor/handlers/base.py b/src/mcp_text_editor/handlers/base.py index f7c3b9a..0d0fa78 100644 --- a/src/mcp_text_editor/handlers/base.py +++ b/src/mcp_text_editor/handlers/base.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Sequence -from mcp.types import TextContent, Tool +from mcp.types import TextContent from ..text_editor import TextEditor @@ -10,17 +10,10 @@ class BaseHandler: """Base class for handlers.""" - name: str = "" - description: str = "" - def __init__(self, editor: TextEditor | None = None): """Initialize the handler.""" self.editor = editor if editor is not None else TextEditor() - def get_tool_description(self) -> Tool: - """Get the tool description.""" - raise NotImplementedError - async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]: """Execute the tool with given arguments.""" raise NotImplementedError diff --git a/src/mcp_text_editor/server.py b/src/mcp_text_editor/server.py index a1265ed..a742c54 100644 --- a/src/mcp_text_editor/server.py +++ b/src/mcp_text_editor/server.py @@ -1,12 +1,10 @@ """MCP Text Editor Server implementation.""" import logging -import traceback -from collections.abc import Sequence -from typing import Any, List +from typing import Sequence -from mcp.server import Server -from mcp.types import TextContent, Tool +from mcp.server.fastmcp import FastMCP +from mcp.types import TextContent from .handlers import ( AppendTextFileContentsHandler, @@ -22,9 +20,9 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger("mcp-text-editor") -app = Server("mcp-text-editor") +app = FastMCP("mcp-text-editor") -# Initialize tool handlers +# Initialize handlers get_contents_handler = GetTextFileContentsHandler() patch_file_handler = PatchTextFileContentsHandler() create_file_handler = CreateTextFileHandler() @@ -33,58 +31,53 @@ insert_file_handler = InsertTextFileContentsHandler() -@app.list_tools() -async def list_tools() -> List[Tool]: - """List available tools.""" - return [ - get_contents_handler.get_tool_description(), - create_file_handler.get_tool_description(), - append_file_handler.get_tool_description(), - delete_contents_handler.get_tool_description(), - insert_file_handler.get_tool_description(), - patch_file_handler.get_tool_description(), - ] - - -@app.call_tool() -async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]: - """Handle tool calls.""" - logger.info(f"Calling tool: {name}") - try: - if name == get_contents_handler.name: - return await get_contents_handler.run_tool(arguments) - elif name == create_file_handler.name: - return await create_file_handler.run_tool(arguments) - elif name == append_file_handler.name: - return await append_file_handler.run_tool(arguments) - elif name == delete_contents_handler.name: - return await delete_contents_handler.run_tool(arguments) - elif name == insert_file_handler.name: - return await insert_file_handler.run_tool(arguments) - elif name == patch_file_handler.name: - return await patch_file_handler.run_tool(arguments) - else: - raise ValueError(f"Unknown tool: {name}") - except ValueError: - logger.error(traceback.format_exc()) - raise - except Exception as e: - logger.error(traceback.format_exc()) - raise RuntimeError(f"Error executing command: {str(e)}") from e +# Register tools +@app.tool() +async def get_text_file_contents(path: str) -> Sequence[TextContent]: + """Get the contents of a text file.""" + return await get_contents_handler.run_tool({"path": path}) + + +@app.tool() +async def patch_text_file_contents(path: str, content: str) -> Sequence[TextContent]: + """Patch the contents of a text file.""" + return await patch_file_handler.run_tool({"path": path, "content": content}) + + +@app.tool() +async def create_text_file(path: str) -> Sequence[TextContent]: + """Create a new text file.""" + return await create_file_handler.run_tool({"path": path}) + + +@app.tool() +async def append_text_file_contents(path: str, content: str) -> Sequence[TextContent]: + """Append content to a text file.""" + return await append_file_handler.run_tool({"path": path, "content": content}) + + +@app.tool() +async def delete_text_file_contents(path: str) -> Sequence[TextContent]: + """Delete the contents of a text file.""" + return await delete_contents_handler.run_tool({"path": path}) + + +@app.tool() +async def insert_text_file_contents( + path: str, content: str, position: int +) -> Sequence[TextContent]: + """Insert content into a text file at a specific position.""" + return await insert_file_handler.run_tool( + {"path": path, "content": content, "position": position} + ) async def main() -> None: """Main entry point for the MCP text editor server.""" logger.info(f"Starting MCP text editor server v{__version__}") - try: - from mcp.server.stdio import stdio_server - - async with stdio_server() as (read_stream, write_stream): - await app.run( - read_stream, - write_stream, - app.create_initialization_options(), - ) - except Exception as e: - logger.error(f"Server error: {str(e)}") - raise + await app.run() # type: ignore[func-returns-value] + + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) diff --git a/src/mcp_text_editor/text_editor.py b/src/mcp_text_editor/text_editor.py index 360bd2c..92f2af3 100644 --- a/src/mcp_text_editor/text_editor.py +++ b/src/mcp_text_editor/text_editor.py @@ -150,6 +150,7 @@ async def read_multiple_ranges( if end_value is not None else total_lines ) + end_1based = end_value if end_value is not None else total_lines if start >= total_lines: empty_content = "" @@ -173,7 +174,7 @@ async def read_multiple_ranges( { "content": content, "start": start + 1, - "end": end, + "end": end_1based, "range_hash": range_hash, "total_lines": total_lines, "content_size": len(content), diff --git a/test_hash_fix.py b/test_hash_fix.py new file mode 100644 index 0000000..4777d9f --- /dev/null +++ b/test_hash_fix.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Test the hash bug fix.""" + +import pytest +from mcp_text_editor.text_editor import TextEditor + +@pytest.mark.asyncio +async def test_hash_consistency(): + """Test that hash is consistent between single and multi-line reads.""" + import tempfile + import os + + editor = TextEditor() + content = "line1\nline2\nline3\nline4\n" + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: + f.write(content) + test_file = f.name + + try: + # Test 1: Read lines 1-3 + ranges = [{ + 'file_path': test_file, + 'ranges': [{'start': 1, 'end': 3}] + }] + + result = await editor.read_multiple_ranges(ranges) + actual_content = result[test_file]['ranges'][0]['content'] + actual_hash = result[test_file]['ranges'][0]['range_hash'] + + expected_content = "line1\nline2\nline3\n" + expected_hash = editor.calculate_hash(expected_content) + + assert actual_content == expected_content, f"Content mismatch: {repr(actual_content)} != {repr(expected_content)}" + assert actual_hash == expected_hash, f"Hash mismatch: {actual_hash} != {expected_hash}" + + # Test 2: end field should be correct + assert result[test_file]['ranges'][0]['end'] == 3 + + finally: + os.unlink(test_file) + +if __name__ == "__main__": + import asyncio + asyncio.run(test_hash_consistency()) diff --git a/tests/conftest.py b/tests/conftest.py index 75ee797..2db14b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ import pytest import pytest_asyncio -from mcp.server import Server +from mcp.server.fastmcp import FastMCP from mcp_text_editor.server import app @@ -58,7 +58,7 @@ async def drain(self) -> None: @pytest_asyncio.fixture -async def mock_server() -> AsyncGenerator[tuple[Server, MockStream], None]: +async def mock_server() -> AsyncGenerator[tuple[FastMCP, MockStream], None]: """Create a mock server for testing.""" mock_write_stream = MockStream() yield app, mock_write_stream diff --git a/tests/test_server.py b/tests/test_server.py index fbd5f12..77b320b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,150 +1,149 @@ """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 mcp.types import TextContent 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, ) +from mcp_text_editor.text_editor import TextEditor -@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.fixture +def editor(): + return TextEditor() @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) +async def test_get_text_file_contents(): + """Test get_text_file_contents tool.""" + test_file = str(Path(__file__).absolute()) + result = await get_contents_handler.run_tool( + {"files": [{"file_path": test_file, "ranges": [{"start": 1, "end": 10}]}]} + ) assert len(result) == 1 + assert isinstance(result[0], TextContent) 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) +async def test_patch_text_file_contents(tmp_path, editor): + """Test patch_text_file_contents tool.""" + test_file = str(tmp_path / "test.txt") + Path(test_file).write_text("test content\n") + content, _, _, file_hash, _, _ = await editor.read_file_contents(test_file) + + result = await patch_file_handler.run_tool( + { + "file_path": test_file, + "file_hash": file_hash, + "patches": [ + { + "start": 1, + "end": 1, + "contents": "new content\n", + "range_hash": file_hash, + } + ], + } + ) 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) + assert result[0].type == "text" + assert Path(test_file).read_text().rstrip() == "new content" @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) +async def test_create_text_file(tmp_path): + """Test create_text_file tool.""" + test_file = str(tmp_path / "new.txt") + result = await create_file_handler.run_tool( + {"file_path": test_file, "contents": "new file content\n"} + ) 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 + assert result[0].type == "text" + assert Path(test_file).exists() + assert Path(test_file).read_text().rstrip() == "new file content" @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) +async def test_append_text_file_contents(tmp_path, editor): + """Test append_text_file_contents tool.""" + test_file = str(tmp_path / "test.txt") + Path(test_file).write_text("initial content") # Remove trailing newline + content, _, _, file_hash, _, _ = await editor.read_file_contents(test_file) + + result = await append_file_handler.run_tool( + { + "file_path": test_file, + "contents": " appended", # Remove trailing newline + "file_hash": file_hash, + } + ) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert Path(test_file).read_text().rstrip() == "initial content appended" @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) +async def test_delete_text_file_contents(tmp_path, editor): + """Test delete_text_file_contents tool.""" + test_file = str(tmp_path / "test.txt") + Path(test_file).write_text("content to delete\n") + content, _, _, file_hash, _, _ = await editor.read_file_contents(test_file) + + result = await delete_contents_handler.run_tool( + { + "file_path": test_file, + "file_hash": file_hash, + "ranges": [{"start": 1, "end": 1, "range_hash": file_hash}], + } + ) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert Path(test_file).read_text().rstrip() == "" @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) +async def test_insert_text_file_contents(tmp_path, editor): + """Test insert_text_file_contents tool.""" + test_file = str(tmp_path / "test.txt") + Path(test_file).write_text("before\nafter\n") + content, _, _, file_hash, _, _ = await editor.read_file_contents(test_file) + + result = await insert_file_handler.run_tool( + { + "file_path": test_file, + "contents": "inserted\n", + "file_hash": file_hash, + "before": 2, # Insert before line 2 + } + ) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert Path(test_file).read_text().rstrip() == "before\ninserted\nafter" @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") + mock_run = mocker.patch.object(app, "run") + mock_run.side_effect = Exception("Stdio server error") with pytest.raises(Exception) as exc_info: await main() @@ -154,96 +153,14 @@ async def test_main_stdio_server_error(mocker: MockerFixture): @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" diff --git a/uv.lock b/uv.lock index e5976ee..16a6975 100644 --- a/uv.lock +++ b/uv.lock @@ -279,7 +279,7 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.3.0" }, { name = "chardet", specifier = ">=5.2.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, - { name = "mcp", specifier = ">=1.1.2" }, + { name = "mcp", specifier = ">=1.2.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.2.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.2.2" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" },