├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .python-version ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── pyproject.toml ├── src └── mcp_text_editor │ ├── __init__.py │ ├── handlers │ ├── __init__.py │ ├── append_text_file_contents.py │ ├── base.py │ ├── create_text_file.py │ ├── delete_text_file_contents.py │ ├── get_text_file_contents.py │ ├── insert_text_file_contents.py │ └── patch_text_file_contents.py │ ├── models.py │ ├── server.py │ ├── service.py │ ├── text_editor.py │ └── version.py ├── tests ├── conftest.py ├── test_append_text_file.py ├── test_create_error_response.py ├── test_create_text_file.py ├── test_delete_file_contents.py ├── test_delete_text_file.py ├── test_error_hints.py ├── test_insert_text_file.py ├── test_insert_text_file_handler.py ├── test_models.py ├── test_patch_text_file.py ├── test_patch_text_file_end_none.py ├── test_server.py ├── test_service.py └── test_text_editor.py └── uv.lock /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | strategy: 7 | matrix: 8 | python-version: ["3.13"] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Install uv 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install uv 25 | 26 | - name: Install dev/test dependencies 27 | run: | 28 | pip install -e ".[dev]" 29 | pip install -e ".[test]" 30 | 31 | - name: Run tests 32 | run: | 33 | make check 34 | 35 | publish: 36 | needs: test 37 | runs-on: ubuntu-latest 38 | environment: release 39 | permissions: 40 | id-token: write 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | 45 | - name: Update version from tag 46 | run: | 47 | # Strip 'v' prefix from tag and update version.py 48 | VERSION=${GITHUB_REF#refs/tags/v} 49 | echo "__version__ = \"${VERSION}\"" > src/mcp_text_editor/version.py 50 | 51 | - name: Set up Python ${{ matrix.python-version }} 52 | uses: actions/setup-python@v5 53 | with: 54 | python-version: ${{ matrix.python-version }} 55 | 56 | - name: Install uv 57 | run: | 58 | python -m pip install --upgrade pip 59 | pip install uv 60 | 61 | - name: Build package 62 | run: | 63 | uv build 64 | 65 | - name: Publish to PyPI 66 | env: 67 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 68 | run: | 69 | uv publish --token $PYPI_TOKEN 70 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [main, develop] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.13"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install uv 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install uv 28 | 29 | - name: Install dev/test dependencies 30 | run: | 31 | pip install -e ".[dev]" 32 | pip install -e ".[test]" 33 | 34 | - name: Run lint and typecheck 35 | run: | 36 | make lint typecheck 37 | 38 | - name: Run tests with coverage 39 | run: | 40 | pytest --cov --cov-report=xml 41 | 42 | - name: Upload coverage to Codecov 43 | uses: codecov/codecov-action@v5 44 | with: 45 | token: ${{ secrets.CODECOV_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | MANIFEST 23 | 24 | # Virtual Environment 25 | .env 26 | .venv 27 | env/ 28 | venv/ 29 | ENV/ 30 | 31 | # IDE 32 | .idea/ 33 | .vscode/ 34 | *.swp 35 | *.swo 36 | 37 | # Testing 38 | *.cover 39 | *,cover 40 | .coverage 41 | .coverage.* 42 | .pytest_cache/ 43 | htmlcov/ 44 | 45 | # Distribution 46 | *.tar.gz 47 | 48 | # Logs 49 | *.log 50 | prompt.md 51 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.0] - 2024-12-23 4 | 5 | ### Added 6 | 7 | - New text file manipulation operations: 8 | - `insert_text_file_contents`: Insert content at specific positions 9 | - `create_text_file`: Create new text files 10 | - `append_text_file_contents`: Append content to existing files 11 | - `delete_text_file_contents`: Delete specified ranges of text 12 | - `patch_text_file_contents`: Apply multiple patches to text files 13 | - Enhanced error messages with useful suggestions for alternative editing methods 14 | 15 | ### Changed 16 | 17 | - Unified parameter naming: renamed 'path' to 'file_path' across all APIs 18 | - Improved handler organization by moving them to separate directory 19 | - Made 'end' parameter required when not in append mode 20 | - Enhanced validation for required parameters and file path checks 21 | - Removed 'edit_text_file_contents' tool in favor of more specific operations 22 | - Improved JSON serialization for handler responses 23 | 24 | ### Fixed 25 | 26 | - Delete operation now uses dedicated deletion method instead of empty content replacement 27 | - Improved range validation in delete operations 28 | - Enhanced error handling across all operations 29 | - Removed file hash from error responses for better clarity 30 | - Fixed concurrency control with proper hash validation 31 | 32 | ## [1.0.2] - 2024-12-22 33 | 34 | ### Fixed 35 | 36 | - Remove unexpected print logs 37 | 38 | ## [1.0.1] - 2024-12-17 39 | 40 | ### Added 41 | 42 | - Support for custom file encoding options 43 | - New file creation and line insertion capabilities 44 | - Absolute path enforcement for file operations 45 | - Append mode support for adding content at the end of files 46 | - Range hash validation for content integrity 47 | 48 | ### Fixed 49 | 50 | - Improved error messages and handling for file operations 51 | - Enhanced file hash verification logic 52 | - Better handling of empty file content 53 | - Unified file_hash field naming across responses 54 | 55 | ### Changed 56 | 57 | - Migrated to Pydantic models for better type validation 58 | - Simplified server code and improved consistency 59 | - Enhanced test coverage and code organization 60 | - Updated documentation for clarity 61 | 62 | ## [1.0.0] - Initial Release 63 | 64 | - Line-oriented text editor functionality 65 | - Basic file operation support 66 | - Hash-based conflict detection 67 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a Python image with uv pre-installed 2 | FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS uv 3 | 4 | # Install the project into `/app` 5 | WORKDIR /app 6 | 7 | # Enable bytecode compilation 8 | ENV UV_COMPILE_BYTECODE=1 9 | 10 | # Copy from the cache instead of linking since it's a mounted volume 11 | ENV UV_LINK_MODE=copy 12 | 13 | # Install the project's dependencies using the lockfile and settings 14 | RUN --mount=type=cache,target=/root/.cache/uv \ 15 | --mount=type=bind,source=uv.lock,target=uv.lock \ 16 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 17 | uv sync --frozen --no-install-project --no-dev --no-editable 18 | 19 | # Then, add the rest of the project source code and install it 20 | # Installing separately from its dependencies allows optimal layer caching 21 | ADD . /app 22 | RUN --mount=type=cache,target=/root/.cache/uv \ 23 | uv sync --frozen --no-dev --no-editable 24 | 25 | FROM python:3.13-slim-bookworm 26 | 27 | WORKDIR /app 28 | 29 | # COPY --from=uv /root/.local /root/.local 30 | COPY --from=uv --chown=app:app /app/.venv /app/.venv 31 | 32 | # Copy the source code 33 | COPY --from=uv --chown=app:app /app/src /app/src 34 | 35 | # Place executables in the environment at the front of the path 36 | ENV PATH="/app/.venv/bin:$PATH" 37 | 38 | # Run mcp server 39 | ENTRYPOINT ["python", "src/mcp_text_editor/server.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 tumf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test format lint typecheck check coverage 2 | .DEFAULT_GOAL := all 3 | 4 | test: 5 | pytest 6 | 7 | install: 8 | uv sync --all-extras 9 | 10 | coverage: 11 | pytest --cov=mcp_text_editor --cov-report=term-missing 12 | 13 | format: 14 | black src tests 15 | isort src tests 16 | ruff check --fix src tests 17 | 18 | 19 | lint: 20 | black --check src tests 21 | isort --check src tests 22 | ruff check src tests 23 | 24 | typecheck: 25 | mypy src tests 26 | 27 | # Run all checks required before pushing 28 | check: lint typecheck 29 | fix: format 30 | all: format typecheck coverage 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Text Editor Server 2 | 3 | [![codecov](https://codecov.io/gh/tumf/mcp-text-editor/branch/main/graph/badge.svg?token=52D51U0ZUR)](https://codecov.io/gh/tumf/mcp-text-editor) 4 | [![smithery badge](https://smithery.ai/badge/mcp-text-editor)](https://smithery.ai/server/mcp-text-editor) 5 | [![Glama MCP Server](https://glama.ai/mcp/servers/k44dnvso10/badge)](https://glama.ai/mcp/servers/k44dnvso10) 6 | [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/tumf-mcp-text-editor-badge.png)](https://mseep.ai/app/tumf-mcp-text-editor) 7 | 8 | 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. 9 | 10 | ## Quick Start for Claude.app Users 11 | 12 | To use this editor with Claude.app, add the following configuration to your prompt: 13 | 14 | ```shell 15 | code ~/Library/Application\ Support/Claude/claude_desktop_config.json 16 | ``` 17 | 18 | ```json 19 | { 20 | "mcpServers": { 21 | "text-editor": { 22 | "command": "uvx", 23 | "args": [ 24 | "mcp-text-editor" 25 | ] 26 | } 27 | } 28 | } 29 | ``` 30 | 31 | or with docker: 32 | ```json 33 | { 34 | "mcpServers": { 35 | "text-editor": { 36 | "command": "docker", 37 | "args": [ 38 | "run", 39 | "-i", 40 | "--rm", 41 | "--mount", 42 | "type=bind,src=/some/path/src,dst=/some/path/dst", 43 | "mcp/text-editor" 44 | ] 45 | } 46 | } 47 | } 48 | ``` 49 | 50 | ## Overview 51 | 52 | MCP Text Editor Server is designed to facilitate safe and efficient line-based text file operations in a client-server architecture. It implements the Model Context Protocol, ensuring reliable file editing with robust conflict detection and resolution. The line-oriented approach makes it ideal for applications requiring synchronized file access, such as collaborative editing tools, automated text processing systems, or any scenario where multiple processes need to modify text files safely. The partial file access capability is particularly valuable for LLM-based tools, as it helps reduce token consumption by loading only the necessary portions of files. 53 | 54 | ### Key Benefits 55 | 56 | - Line-based editing operations 57 | - Token-efficient partial file access with line-range specifications 58 | - Optimized for LLM tool integration 59 | - Safe concurrent editing with hash-based validation 60 | - Atomic multi-file operations 61 | - Robust error handling with custom error types 62 | - Comprehensive encoding support (utf-8, shift_jis, latin1, etc.) 63 | 64 | ## Features 65 | 66 | - Line-oriented text file editing and reading 67 | - Smart partial file access to minimize token usage in LLM applications 68 | - Get text file contents with line range specification 69 | - Read multiple ranges from multiple files in a single operation 70 | - Line-based patch application with correct handling of line number shifts 71 | - Edit text file contents with conflict detection 72 | - Flexible character encoding support (utf-8, shift_jis, latin1, etc.) 73 | - Support for multiple file operations 74 | - Proper handling of concurrent edits with hash-based validation 75 | - Memory-efficient processing of large files 76 | 77 | ## Requirements 78 | 79 | - Python 3.11 or higher 80 | - POSIX-compliant operating system (Linux, macOS, etc.) or Windows 81 | - Sufficient disk space for text file operations 82 | - File system permissions for read/write operations 83 | 84 | 1. Install Python 3.11+ 85 | 86 | ```bash 87 | pyenv install 3.11.6 88 | pyenv local 3.11.6 89 | ``` 90 | 91 | 2. Install uv (recommended) or pip 92 | 93 | ```bash 94 | curl -LsSf https://astral.sh/uv/install.sh | sh 95 | ``` 96 | 97 | 3. Create virtual environment and install dependencies 98 | 99 | ```bash 100 | uv venv 101 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 102 | uv pip install -e ".[dev]" 103 | ``` 104 | 105 | ## Requirements 106 | 107 | - Python 3.13+ 108 | - POSIX-compliant operating system (Linux, macOS, etc.) or Windows 109 | - File system permissions for read/write operations 110 | 111 | ## Installation 112 | 113 | ### Run via uvx 114 | 115 | ```bash 116 | uvx mcp-text-editor 117 | ``` 118 | 119 | ### Installing via Smithery 120 | 121 | To install Text Editor Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/mcp-text-editor): 122 | 123 | ```bash 124 | npx -y @smithery/cli install mcp-text-editor --client claude 125 | ``` 126 | 127 | ### Manual Installation 128 | 129 | 1. Install Python 3.13+ 130 | 131 | ```bash 132 | pyenv install 3.13.0 133 | pyenv local 3.13.0 134 | ``` 135 | 136 | ### Docker Installation 137 | ``` 138 | docker build --network=host -t mcp/text-editor . 139 | ``` 140 | 141 | 2. Install uv (recommended) or pip 142 | 143 | ```bash 144 | curl -LsSf https://astral.sh/uv/install.sh | sh 145 | ``` 146 | 147 | 3. Create virtual environment and install dependencies 148 | 149 | ```bash 150 | uv venv 151 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 152 | uv pip install -e ".[dev]" 153 | ``` 154 | 155 | ## Usage 156 | 157 | Start the server: 158 | 159 | ```bash 160 | python -m mcp_text_editor 161 | ``` 162 | 163 | Start the server with docker: 164 | 165 | ```bash 166 | docker run -i --rm --mount "type=bind,src=/some/path/src,dst=/some/path/dst" mcp/text-editor 167 | ``` 168 | 169 | with inspector: 170 | 171 | ```bash 172 | npx @modelcontextprotocol/inspector docker run -i --rm --mount "type=bind,src=/some/path/src,dst=/some/path/dst" mcp/text-editor 173 | ``` 174 | 175 | ### MCP Tools 176 | 177 | The server provides several tools for text file manipulation: 178 | 179 | #### get_text_file_contents 180 | 181 | Get the contents of one or more text files with line range specification. 182 | 183 | **Single Range Request:** 184 | 185 | ```json 186 | { 187 | "file_path": "path/to/file.txt", 188 | "line_start": 1, 189 | "line_end": 10, 190 | "encoding": "utf-8" // Optional, defaults to utf-8 191 | } 192 | ``` 193 | 194 | **Multiple Ranges Request:** 195 | 196 | ```json 197 | { 198 | "files": [ 199 | { 200 | "file_path": "file1.txt", 201 | "ranges": [ 202 | {"start": 1, "end": 10}, 203 | {"start": 20, "end": 30} 204 | ], 205 | "encoding": "shift_jis" // Optional, defaults to utf-8 206 | }, 207 | { 208 | "file_path": "file2.txt", 209 | "ranges": [ 210 | {"start": 5, "end": 15} 211 | ] 212 | } 213 | ] 214 | } 215 | ``` 216 | 217 | Parameters: 218 | - `file_path`: Path to the text file 219 | - `line_start`/`start`: Line number to start from (1-based) 220 | - `line_end`/`end`: Line number to end at (inclusive, null for end of file) 221 | - `encoding`: File encoding (default: "utf-8"). Specify the encoding of the text file (e.g., "shift_jis", "latin1") 222 | 223 | **Single Range Response:** 224 | 225 | ```json 226 | { 227 | "contents": "File contents", 228 | "line_start": 1, 229 | "line_end": 10, 230 | "hash": "sha256-hash-of-contents", 231 | "file_lines": 50, 232 | "file_size": 1024 233 | } 234 | ``` 235 | 236 | **Multiple Ranges Response:** 237 | 238 | ```json 239 | { 240 | "file1.txt": [ 241 | { 242 | "content": "Lines 1-10 content", 243 | "start": 1, 244 | "end": 10, 245 | "hash": "sha256-hash-1", 246 | "total_lines": 50, 247 | "content_size": 512 248 | }, 249 | { 250 | "content": "Lines 20-30 content", 251 | "start": 20, 252 | "end": 30, 253 | "hash": "sha256-hash-2", 254 | "total_lines": 50, 255 | "content_size": 512 256 | } 257 | ], 258 | "file2.txt": [ 259 | { 260 | "content": "Lines 5-15 content", 261 | "start": 5, 262 | "end": 15, 263 | "hash": "sha256-hash-3", 264 | "total_lines": 30, 265 | "content_size": 256 266 | } 267 | ] 268 | } 269 | ``` 270 | 271 | #### patch_text_file_contents 272 | 273 | Apply patches to text files with robust error handling and conflict detection. Supports editing multiple files in a single operation. 274 | 275 | **Request Format:** 276 | 277 | ```json 278 | { 279 | "files": [ 280 | { 281 | "file_path": "file1.txt", 282 | "hash": "sha256-hash-from-get-contents", 283 | "encoding": "utf-8", // Optional, defaults to utf-8 284 | "patches": [ 285 | { 286 | "start": 5, 287 | "end": 8, 288 | "range_hash": "sha256-hash-of-content-being-replaced", 289 | "contents": "New content for lines 5-8\n" 290 | }, 291 | { 292 | "start": 15, 293 | "end": null, // null means end of file 294 | "range_hash": "sha256-hash-of-content-being-replaced", 295 | "contents": "Content to append\n" 296 | } 297 | ] 298 | } 299 | ] 300 | } 301 | ``` 302 | 303 | Important Notes: 304 | 1. Always get the current hash and range_hash using get_text_file_contents before editing 305 | 2. Patches are applied from bottom to top to handle line number shifts correctly 306 | 3. Patches must not overlap within the same file 307 | 4. Line numbers are 1-based 308 | 5. `end: null` can be used to append content to the end of file 309 | 6. File encoding must match the encoding used in get_text_file_contents 310 | 311 | **Success Response:** 312 | 313 | ```json 314 | { 315 | "file1.txt": { 316 | "result": "ok", 317 | "hash": "sha256-hash-of-new-contents" 318 | } 319 | } 320 | ``` 321 | 322 | **Error Response with Hints:** 323 | 324 | ```json 325 | { 326 | "file1.txt": { 327 | "result": "error", 328 | "reason": "Content hash mismatch", 329 | "suggestion": "get", // Suggests using get_text_file_contents 330 | "hint": "Please run get_text_file_contents first to get current content and hashes" 331 | } 332 | } 333 | ``` 334 | 335 | "result": "error", 336 | "reason": "Content hash mismatch - file was modified", 337 | "hash": "current-hash", 338 | "content": "Current file content" 339 | 340 | } 341 | } 342 | 343 | ``` 344 | 345 | ### Common Usage Pattern 346 | 347 | 1. Get current content and hash: 348 | 349 | ```python 350 | contents = await get_text_file_contents({ 351 | "files": [ 352 | { 353 | "file_path": "file.txt", 354 | "ranges": [{"start": 1, "end": null}] # Read entire file 355 | } 356 | ] 357 | }) 358 | ``` 359 | 360 | 2. Edit file content: 361 | 362 | ```python 363 | result = await edit_text_file_contents({ 364 | "files": [ 365 | { 366 | "path": "file.txt", 367 | "hash": contents["file.txt"][0]["hash"], 368 | "encoding": "utf-8", # Optional, defaults to "utf-8" 369 | "patches": [ 370 | { 371 | "line_start": 5, 372 | "line_end": 8, 373 | "contents": "New content\n" 374 | } 375 | ] 376 | } 377 | ] 378 | }) 379 | ``` 380 | 381 | 3. Handle conflicts: 382 | 383 | ```python 384 | if result["file.txt"]["result"] == "error": 385 | if "hash mismatch" in result["file.txt"]["reason"]: 386 | # File was modified by another process 387 | # Get new content and retry 388 | pass 389 | ``` 390 | 391 | ### Error Handling 392 | 393 | The server handles various error cases: 394 | - File not found 395 | - Permission errors 396 | - Hash mismatches (concurrent edit detection) 397 | - Invalid patch ranges 398 | - Overlapping patches 399 | - Encoding errors (when file cannot be decoded with specified encoding) 400 | - Line number out of bounds 401 | 402 | ## Security Considerations 403 | 404 | - File Path Validation: The server validates all file paths to prevent directory traversal attacks 405 | - Access Control: Proper file system permissions should be set to restrict access to authorized directories 406 | - Hash Validation: All file modifications are validated using SHA-256 hashes to prevent race conditions 407 | - Input Sanitization: All user inputs are properly sanitized and validated 408 | - Error Handling: Sensitive information is not exposed in error messages 409 | 410 | ## Troubleshooting 411 | 412 | ### Common Issues 413 | 414 | 1. Permission Denied 415 | - Check file and directory permissions 416 | - Ensure the server process has necessary read/write access 417 | 418 | 2. Hash Mismatch and Range Hash Errors 419 | - The file was modified by another process 420 | - Content being replaced has changed 421 | - Run get_text_file_contents to get fresh hashes 422 | 423 | 3. Encoding Issues 424 | - Verify file encoding matches the specified encoding 425 | - Use utf-8 for new files 426 | - Check for BOM markers in files 427 | 428 | 4. Connection Issues 429 | - Verify the server is running and accessible 430 | - Check network configuration and firewall settings 431 | 432 | 5. Performance Issues 433 | - Consider using smaller line ranges for large files 434 | - Monitor system resources (memory, disk space) 435 | - Use appropriate encoding for file type 436 | 437 | ## Development 438 | 439 | ### Setup 440 | 441 | 1. Clone the repository 442 | 2. Create and activate a Python virtual environment 443 | 3. Install development dependencies: `uv pip install -e ".[dev]"` 444 | 4. Run tests: `make all` 445 | 446 | ### Code Quality Tools 447 | 448 | - Ruff for linting 449 | - Black for code formatting 450 | - isort for import sorting 451 | - mypy for type checking 452 | - pytest-cov for test coverage 453 | 454 | ### Testing 455 | 456 | Tests are located in the `tests` directory and can be run with pytest: 457 | 458 | ```bash 459 | # Run all tests 460 | pytest 461 | 462 | # Run tests with coverage report 463 | pytest --cov=mcp_text_editor --cov-report=term-missing 464 | 465 | # Run specific test file 466 | pytest tests/test_text_editor.py -v 467 | ``` 468 | 469 | Current test coverage: 90% 470 | 471 | ### Project Structure 472 | 473 | ``` 474 | mcp-text-editor/ 475 | ├── mcp_text_editor/ 476 | │ ├── __init__.py 477 | │ ├── __main__.py # Entry point 478 | │ ├── models.py # Data models 479 | │ ├── server.py # MCP Server implementation 480 | │ ├── service.py # Core service logic 481 | │ └── text_editor.py # Text editor functionality 482 | ├── tests/ # Test files 483 | └── pyproject.toml # Project configuration 484 | ``` 485 | 486 | ## License 487 | 488 | MIT 489 | 490 | ## Contributing 491 | 492 | 1. Fork the repository 493 | 2. Create a feature branch 494 | 3. Make your changes 495 | 4. Run tests and code quality checks 496 | 5. Submit a pull request 497 | 498 | ### Type Hints 499 | 500 | This project uses Python type hints throughout the codebase. Please ensure any contributions maintain this. 501 | 502 | ### Error Handling 503 | 504 | All error cases should be handled appropriately and return meaningful error messages. The server should never crash due to invalid input or file operations. 505 | 506 | ### Testing 507 | 508 | New features should include appropriate tests. Try to maintain or improve the current test coverage. 509 | 510 | ### Code Style 511 | 512 | All code should be formatted with Black and pass Ruff linting. Import sorting should be handled by isort. 513 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mcp-text-editor" 3 | dynamic = ["version"] 4 | description = "MCP Text Editor Server - Edit text files via MCP protocol" 5 | authors = [ 6 | { name = "tumf" } 7 | ] 8 | dependencies = [ 9 | "asyncio>=3.4.3", 10 | "mcp>=1.1.2", 11 | "chardet>=5.2.0", 12 | ] 13 | requires-python = ">=3.13" 14 | readme = "README.md" 15 | license = { text = "MIT" } 16 | 17 | [project.scripts] 18 | mcp-text-editor = "mcp_text_editor:run" 19 | 20 | [project.optional-dependencies] 21 | test = [ 22 | "pytest>=8.3.4", 23 | "pytest-asyncio>=0.24.0", 24 | "pytest-env>=1.1.0", 25 | "pytest-cov>=6.0.0", 26 | "pytest-mock>=3.12.0", 27 | ] 28 | dev = [ 29 | "ruff>=0.0.262", 30 | "black>=23.3.0", 31 | "isort>=5.12.0", 32 | "mypy>=1.2.0", 33 | "pre-commit>=3.2.2", 34 | ] 35 | 36 | [build-system] 37 | requires = ["hatchling"] 38 | build-backend = "hatchling.build" 39 | 40 | [tool.pytest.ini_options] 41 | asyncio_mode = "strict" 42 | testpaths = "tests" 43 | asyncio_default_fixture_loop_scope = "function" 44 | pythonpath = ["src"] 45 | 46 | [tool.ruff] 47 | lint.select = [ 48 | "E", # pycodestyle errors 49 | "F", # pyflakes 50 | "W", # pycodestyle warnings 51 | "I", # isort 52 | "C", # flake8-comprehensions 53 | "B", # flake8-bugbear 54 | ] 55 | lint.ignore = [ 56 | "E501", # line too long, handled by black 57 | "B008", # do not perform function calls in argument defaults 58 | "C901", # too complex 59 | ] 60 | lint.extend-select = ["I"] 61 | line-length = 88 62 | src = ["src"] 63 | 64 | [tool.black] 65 | line-length = 88 66 | target-version = ['py313'] 67 | 68 | [tool.isort] 69 | profile = "black" 70 | line_length = 88 71 | 72 | [tool.mypy] 73 | python_version = "3.13" 74 | ignore_missing_imports = true 75 | namespace_packages = true 76 | explicit_package_bases = true 77 | mypy_path = "src" 78 | 79 | [tool.hatch.build.targets.wheel] 80 | 81 | 82 | [tool.hatch.version] 83 | path = "src/mcp_text_editor/version.py" 84 | 85 | [tool.coverage.run] 86 | source = ["mcp_text_editor"] 87 | branch = true 88 | 89 | [tool.coverage.report] 90 | exclude_lines = [ 91 | "pragma: no cover", 92 | "def __repr__", 93 | "raise NotImplementedError", 94 | "if __name__ == .__main__.:", 95 | "pass", 96 | "raise ImportError", 97 | "__version__", 98 | "if TYPE_CHECKING:", 99 | "raise FileNotFoundError", 100 | "raise ValueError", 101 | "raise RuntimeError", 102 | "raise OSError", 103 | "except Exception as e:", 104 | "except ValueError:", 105 | "except FileNotFoundError:", 106 | "except OSError as e:", 107 | "except Exception:", 108 | "if not os.path.exists", 109 | "if os.path.exists", 110 | "def __init__", 111 | ] 112 | 113 | omit = [ 114 | "src/mcp_text_editor/__init__.py", 115 | "src/mcp_text_editor/version.py", 116 | ] 117 | -------------------------------------------------------------------------------- /src/mcp_text_editor/__init__.py: -------------------------------------------------------------------------------- 1 | """MCP Text Editor Server package.""" 2 | 3 | import asyncio 4 | 5 | from .server import main 6 | from .text_editor import TextEditor 7 | 8 | # Create a global text editor instance 9 | _text_editor = TextEditor() 10 | 11 | 12 | def run() -> None: 13 | """Run the MCP Text Editor Server.""" 14 | asyncio.run(main()) 15 | -------------------------------------------------------------------------------- /src/mcp_text_editor/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | """Handlers for MCP Text Editor.""" 2 | 3 | from .append_text_file_contents import AppendTextFileContentsHandler 4 | from .create_text_file import CreateTextFileHandler 5 | from .delete_text_file_contents import DeleteTextFileContentsHandler 6 | from .get_text_file_contents import GetTextFileContentsHandler 7 | from .insert_text_file_contents import InsertTextFileContentsHandler 8 | from .patch_text_file_contents import PatchTextFileContentsHandler 9 | 10 | __all__ = [ 11 | "AppendTextFileContentsHandler", 12 | "CreateTextFileHandler", 13 | "DeleteTextFileContentsHandler", 14 | "GetTextFileContentsHandler", 15 | "InsertTextFileContentsHandler", 16 | "PatchTextFileContentsHandler", 17 | ] 18 | -------------------------------------------------------------------------------- /src/mcp_text_editor/handlers/append_text_file_contents.py: -------------------------------------------------------------------------------- 1 | """Handler for appending content to text files.""" 2 | 3 | import json 4 | import logging 5 | import os 6 | import traceback 7 | from typing import Any, Dict, Sequence 8 | 9 | from mcp.types import TextContent, Tool 10 | 11 | from mcp_text_editor.handlers.base import BaseHandler 12 | 13 | logger = logging.getLogger("mcp-text-editor") 14 | 15 | 16 | class AppendTextFileContentsHandler(BaseHandler): 17 | """Handler for appending content to an existing text file.""" 18 | 19 | name = "append_text_file_contents" 20 | description = "Append content to an existing text file. The file must exist." 21 | 22 | def get_tool_description(self) -> Tool: 23 | """Get the tool description.""" 24 | return Tool( 25 | name=self.name, 26 | description=self.description, 27 | inputSchema={ 28 | "type": "object", 29 | "properties": { 30 | "file_path": { 31 | "type": "string", 32 | "description": "Path to the text file. File path must be absolute.", 33 | }, 34 | "contents": { 35 | "type": "string", 36 | "description": "Content to append to the file", 37 | }, 38 | "file_hash": { 39 | "type": "string", 40 | "description": "Hash of the file contents for concurrency control. it should be matched with the file_hash when get_text_file_contents is called.", 41 | }, 42 | "encoding": { 43 | "type": "string", 44 | "description": "Text encoding (default: 'utf-8')", 45 | "default": "utf-8", 46 | }, 47 | }, 48 | "required": ["file_path", "contents", "file_hash"], 49 | }, 50 | ) 51 | 52 | async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]: 53 | """Execute the tool with given arguments.""" 54 | try: 55 | if "file_path" not in arguments: 56 | raise RuntimeError("Missing required argument: file_path") 57 | if "contents" not in arguments: 58 | raise RuntimeError("Missing required argument: contents") 59 | if "file_hash" not in arguments: 60 | raise RuntimeError("Missing required argument: file_hash") 61 | 62 | file_path = arguments["file_path"] 63 | if not os.path.isabs(file_path): 64 | raise RuntimeError(f"File path must be absolute: {file_path}") 65 | 66 | # Check if file exists 67 | if not os.path.exists(file_path): 68 | raise RuntimeError(f"File does not exist: {file_path}") 69 | 70 | encoding = arguments.get("encoding", "utf-8") 71 | 72 | # Check file contents and hash before modification 73 | # Get file information and verify hash 74 | content, _, _, current_hash, total_lines, _ = ( 75 | await self.editor.read_file_contents(file_path, encoding=encoding) 76 | ) 77 | 78 | # Verify file hash 79 | if current_hash != arguments["file_hash"]: 80 | raise RuntimeError("File hash mismatch - file may have been modified") 81 | 82 | # Ensure the append content ends with newline 83 | append_content = arguments["contents"] 84 | if not append_content.endswith("\n"): 85 | append_content += "\n" 86 | 87 | # Create patch for append operation 88 | result = await self.editor.edit_file_contents( 89 | file_path, 90 | expected_file_hash=arguments["file_hash"], 91 | patches=[ 92 | { 93 | "start": total_lines + 1, 94 | "end": None, 95 | "contents": append_content, 96 | "range_hash": "", 97 | } 98 | ], 99 | encoding=encoding, 100 | ) 101 | 102 | return [TextContent(type="text", text=json.dumps(result, indent=2))] 103 | 104 | except Exception as e: 105 | logger.error(f"Error processing request: {str(e)}") 106 | logger.error(traceback.format_exc()) 107 | raise RuntimeError(f"Error processing request: {str(e)}") from e 108 | -------------------------------------------------------------------------------- /src/mcp_text_editor/handlers/base.py: -------------------------------------------------------------------------------- 1 | """Base handler for MCP Text Editor.""" 2 | 3 | from typing import Any, Dict, Sequence 4 | 5 | from mcp.types import TextContent, Tool 6 | 7 | from mcp_text_editor.text_editor import TextEditor 8 | 9 | 10 | class BaseHandler: 11 | """Base class for handlers.""" 12 | 13 | name: str = "" 14 | description: str = "" 15 | 16 | def __init__(self, editor: TextEditor | None = None): 17 | """Initialize the handler.""" 18 | self.editor = editor if editor is not None else TextEditor() 19 | 20 | def get_tool_description(self) -> Tool: 21 | """Get the tool description.""" 22 | raise NotImplementedError 23 | 24 | async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]: 25 | """Execute the tool with given arguments.""" 26 | raise NotImplementedError 27 | -------------------------------------------------------------------------------- /src/mcp_text_editor/handlers/create_text_file.py: -------------------------------------------------------------------------------- 1 | """Handler for creating new text files.""" 2 | 3 | import json 4 | import logging 5 | import os 6 | import traceback 7 | from typing import Any, Dict, Sequence 8 | 9 | from mcp.types import TextContent, Tool 10 | 11 | from mcp_text_editor.handlers.base import BaseHandler 12 | 13 | logger = logging.getLogger("mcp-text-editor") 14 | 15 | 16 | class CreateTextFileHandler(BaseHandler): 17 | """Handler for creating a new text file.""" 18 | 19 | name = "create_text_file" 20 | description = ( 21 | "Create a new text file with given content. The file must not exist already." 22 | ) 23 | 24 | def get_tool_description(self) -> Tool: 25 | """Get the tool description.""" 26 | return Tool( 27 | name=self.name, 28 | description=self.description, 29 | inputSchema={ 30 | "type": "object", 31 | "properties": { 32 | "file_path": { 33 | "type": "string", 34 | "description": "Path to the text file. File path must be absolute.", 35 | }, 36 | "contents": { 37 | "type": "string", 38 | "description": "Content to write to the file", 39 | }, 40 | "encoding": { 41 | "type": "string", 42 | "description": "Text encoding (default: 'utf-8')", 43 | "default": "utf-8", 44 | }, 45 | }, 46 | "required": ["file_path", "contents"], 47 | }, 48 | ) 49 | 50 | async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]: 51 | """Execute the tool with given arguments.""" 52 | try: 53 | if "file_path" not in arguments: 54 | raise RuntimeError("Missing required argument: file_path") 55 | if "contents" not in arguments: 56 | raise RuntimeError("Missing required argument: contents") 57 | 58 | file_path = arguments["file_path"] 59 | if not os.path.isabs(file_path): 60 | raise RuntimeError(f"File path must be absolute: {file_path}") 61 | 62 | # Check if file already exists 63 | if os.path.exists(file_path): 64 | raise RuntimeError(f"File already exists: {file_path}") 65 | 66 | encoding = arguments.get("encoding", "utf-8") 67 | 68 | # Create new file using edit_file_contents with empty expected_hash 69 | result = await self.editor.edit_file_contents( 70 | file_path, 71 | expected_file_hash="", # Empty hash for new file 72 | patches=[ 73 | { 74 | "start": 1, 75 | "end": None, 76 | "contents": arguments["contents"], 77 | "range_hash": "", # Empty range_hash for new file 78 | } 79 | ], 80 | encoding=encoding, 81 | ) 82 | return [TextContent(type="text", text=json.dumps(result, indent=2))] 83 | 84 | except Exception as e: 85 | logger.error(f"Error processing request: {str(e)}") 86 | logger.error(traceback.format_exc()) 87 | raise RuntimeError(f"Error processing request: {str(e)}") from e 88 | -------------------------------------------------------------------------------- /src/mcp_text_editor/handlers/delete_text_file_contents.py: -------------------------------------------------------------------------------- 1 | """Handler for deleting content from text files.""" 2 | 3 | import json 4 | import logging 5 | import os 6 | import traceback 7 | from typing import Any, Dict, Sequence 8 | 9 | from mcp.types import TextContent, Tool 10 | 11 | from mcp_text_editor.handlers.base import BaseHandler 12 | from mcp_text_editor.models import DeleteTextFileContentsRequest, FileRange 13 | 14 | logger = logging.getLogger("mcp-text-editor") 15 | 16 | 17 | class DeleteTextFileContentsHandler(BaseHandler): 18 | """Handler for deleting content from a text file.""" 19 | 20 | name = "delete_text_file_contents" 21 | description = "Delete specified content ranges from a text file. The file must exist. File paths must be absolute. You need to provide the file_hash comes from get_text_file_contents." 22 | 23 | def get_tool_description(self) -> Tool: 24 | """Get the tool description.""" 25 | return Tool( 26 | name=self.name, 27 | description=self.description, 28 | inputSchema={ 29 | "type": "object", 30 | "properties": { 31 | "file_path": { 32 | "type": "string", 33 | "description": "Path to the text file. File path must be absolute.", 34 | }, 35 | "file_hash": { 36 | "type": "string", 37 | "description": "Hash of the file contents for concurrency control. it should be matched with the file_hash when get_text_file_contents is called.", 38 | }, 39 | "ranges": { 40 | "type": "array", 41 | "description": "List of line ranges to delete", 42 | "items": { 43 | "type": "object", 44 | "properties": { 45 | "start": { 46 | "type": "integer", 47 | "description": "Starting line number (1-based)", 48 | }, 49 | "end": { 50 | "type": ["integer", "null"], 51 | "description": "Ending line number (null for end of file)", 52 | }, 53 | "range_hash": { 54 | "type": "string", 55 | "description": "Hash of the content being deleted. it should be matched with the range_hash when get_text_file_contents is called with the same range.", 56 | }, 57 | }, 58 | "required": ["start", "range_hash"], 59 | }, 60 | }, 61 | "encoding": { 62 | "type": "string", 63 | "description": "Text encoding (default: 'utf-8')", 64 | "default": "utf-8", 65 | }, 66 | }, 67 | "required": ["file_path", "file_hash", "ranges"], 68 | }, 69 | ) 70 | 71 | async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]: 72 | """Execute the tool with given arguments.""" 73 | try: 74 | # Input validation 75 | if "file_path" not in arguments: 76 | raise RuntimeError("Missing required argument: file_path") 77 | if "file_hash" not in arguments: 78 | raise RuntimeError("Missing required argument: file_hash") 79 | if "ranges" not in arguments: 80 | raise RuntimeError("Missing required argument: ranges") 81 | 82 | file_path = arguments["file_path"] 83 | if not os.path.isabs(file_path): 84 | raise RuntimeError(f"File path must be absolute: {file_path}") 85 | 86 | # Check if file exists 87 | if not os.path.exists(file_path): 88 | raise RuntimeError(f"File does not exist: {file_path}") 89 | 90 | encoding = arguments.get("encoding", "utf-8") 91 | 92 | # Create file ranges for deletion 93 | ranges = [ 94 | FileRange( 95 | start=r["start"], end=r.get("end"), range_hash=r["range_hash"] 96 | ) 97 | for r in arguments["ranges"] 98 | ] 99 | 100 | # Create delete request 101 | request = DeleteTextFileContentsRequest( 102 | file_path=file_path, 103 | file_hash=arguments["file_hash"], 104 | ranges=ranges, 105 | encoding=encoding, 106 | ) 107 | 108 | # Execute deletion using the service 109 | result_dict = self.editor.service.delete_text_file_contents(request) 110 | 111 | # Convert EditResults to dictionaries 112 | serializable_result = {} 113 | for file_path, edit_result in result_dict.items(): 114 | serializable_result[file_path] = edit_result.to_dict() 115 | 116 | return [ 117 | TextContent(type="text", text=json.dumps(serializable_result, indent=2)) 118 | ] 119 | 120 | except Exception as e: 121 | logger.error(f"Error processing request: {str(e)}") 122 | logger.error(traceback.format_exc()) 123 | raise RuntimeError(f"Error processing request: {str(e)}") from e 124 | -------------------------------------------------------------------------------- /src/mcp_text_editor/handlers/get_text_file_contents.py: -------------------------------------------------------------------------------- 1 | """Handler for getting text file contents.""" 2 | 3 | import json 4 | import os 5 | from typing import Any, Dict, Sequence 6 | 7 | from mcp.types import TextContent, Tool 8 | 9 | from mcp_text_editor.handlers.base import BaseHandler 10 | 11 | 12 | class GetTextFileContentsHandler(BaseHandler): 13 | """Handler for getting text file contents.""" 14 | 15 | name = "get_text_file_contents" 16 | description = ( 17 | "Read text file contents from multiple files and line ranges. " 18 | "Returns file contents with hashes for concurrency control and line numbers for reference. " 19 | "The hashes are used to detect conflicts when editing the files. File paths must be absolute." 20 | ) 21 | 22 | def get_tool_description(self) -> Tool: 23 | """Get the tool description.""" 24 | return Tool( 25 | name=self.name, 26 | description=self.description, 27 | inputSchema={ 28 | "type": "object", 29 | "properties": { 30 | "files": { 31 | "type": "array", 32 | "description": "List of files and their line ranges to read", 33 | "items": { 34 | "type": "object", 35 | "properties": { 36 | "file_path": { 37 | "type": "string", 38 | "description": "Path to the text file. File path must be absolute.", 39 | }, 40 | "ranges": { 41 | "type": "array", 42 | "description": "List of line ranges to read from the file", 43 | "items": { 44 | "type": "object", 45 | "properties": { 46 | "start": { 47 | "type": "integer", 48 | "description": "Starting line number (1-based)", 49 | }, 50 | "end": { 51 | "type": ["integer", "null"], 52 | "description": "Ending line number (null for end of file)", 53 | }, 54 | }, 55 | "required": ["start"], 56 | }, 57 | }, 58 | }, 59 | "required": ["file_path", "ranges"], 60 | }, 61 | }, 62 | "encoding": { 63 | "type": "string", 64 | "description": "Text encoding (default: 'utf-8')", 65 | "default": "utf-8", 66 | }, 67 | }, 68 | "required": ["files"], 69 | }, 70 | ) 71 | 72 | async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]: 73 | """Execute the tool with given arguments.""" 74 | try: 75 | if "files" not in arguments: 76 | raise RuntimeError("Missing required argument: 'files'") 77 | 78 | for file_info in arguments["files"]: 79 | if not os.path.isabs(file_info["file_path"]): 80 | raise RuntimeError( 81 | f"File path must be absolute: {file_info['file_path']}" 82 | ) 83 | 84 | encoding = arguments.get("encoding", "utf-8") 85 | result = await self.editor.read_multiple_ranges( 86 | arguments["files"], encoding=encoding 87 | ) 88 | response = result 89 | 90 | return [TextContent(type="text", text=json.dumps(response, indent=2))] 91 | 92 | except KeyError as e: 93 | raise RuntimeError(f"Missing required argument: '{e}'") from e 94 | except Exception as e: 95 | raise RuntimeError(f"Error processing request: {str(e)}") from e 96 | -------------------------------------------------------------------------------- /src/mcp_text_editor/handlers/insert_text_file_contents.py: -------------------------------------------------------------------------------- 1 | """Handler for inserting content into text files.""" 2 | 3 | import json 4 | import logging 5 | import os 6 | import traceback 7 | from typing import Any, Dict, Sequence 8 | 9 | from mcp.types import TextContent, Tool 10 | 11 | from mcp_text_editor.handlers.base import BaseHandler 12 | 13 | logger = logging.getLogger("mcp-text-editor") 14 | 15 | 16 | class InsertTextFileContentsHandler(BaseHandler): 17 | """Handler for inserting content before or after a specific line in a text file.""" 18 | 19 | name = "insert_text_file_contents" 20 | description = "Insert content before or after a specific line in a text file. Uses hash-based validation for concurrency control. You need to provide the file_hash comes from get_text_file_contents." 21 | 22 | def get_tool_description(self) -> Tool: 23 | """Get the tool description.""" 24 | return Tool( 25 | name=self.name, 26 | description=self.description, 27 | inputSchema={ 28 | "type": "object", 29 | "properties": { 30 | "file_path": { 31 | "type": "string", 32 | "description": "Path to the text file. File path must be absolute.", 33 | }, 34 | "file_hash": { 35 | "type": "string", 36 | "description": "Hash of the file contents for concurrency control. it should be matched with the file_hash when get_text_file_contents is called.", 37 | }, 38 | "contents": { 39 | "type": "string", 40 | "description": "Content to insert", 41 | }, 42 | "before": { 43 | "type": "integer", 44 | "description": "Line number before which to insert content (mutually exclusive with 'after')", 45 | }, 46 | "after": { 47 | "type": "integer", 48 | "description": "Line number after which to insert content (mutually exclusive with 'before')", 49 | }, 50 | "encoding": { 51 | "type": "string", 52 | "description": "Text encoding (default: 'utf-8')", 53 | "default": "utf-8", 54 | }, 55 | }, 56 | "required": ["file_path", "file_hash", "contents"], 57 | }, 58 | ) 59 | 60 | async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]: 61 | """Execute the tool with given arguments.""" 62 | try: 63 | if "file_path" not in arguments: 64 | raise RuntimeError("Missing required argument: file_path") 65 | if "file_hash" not in arguments: 66 | raise RuntimeError("Missing required argument: file_hash") 67 | if "contents" not in arguments: 68 | raise RuntimeError("Missing required argument: contents") 69 | 70 | file_path = arguments["file_path"] 71 | if not os.path.isabs(file_path): 72 | raise RuntimeError(f"File path must be absolute: {file_path}") 73 | 74 | # Check if exactly one of before/after is specified 75 | if ("before" in arguments) == ("after" in arguments): 76 | raise RuntimeError( 77 | "Exactly one of 'before' or 'after' must be specified" 78 | ) 79 | 80 | line_number = ( 81 | arguments.get("before") 82 | if "before" in arguments 83 | else arguments.get("after") 84 | ) 85 | is_before = "before" in arguments 86 | encoding = arguments.get("encoding", "utf-8") 87 | 88 | # Get result from editor 89 | result = await self.editor.insert_text_file_contents( 90 | file_path=file_path, 91 | file_hash=arguments["file_hash"], 92 | contents=arguments["contents"], 93 | before=line_number if is_before else None, 94 | after=None if is_before else line_number, 95 | encoding=encoding, 96 | ) 97 | # Wrap result with file_path key 98 | result = {file_path: result} 99 | return [TextContent(type="text", text=json.dumps(result, indent=2))] 100 | 101 | except Exception as e: 102 | logger.error(f"Error processing request: {str(e)}") 103 | logger.error(traceback.format_exc()) 104 | raise RuntimeError(f"Error processing request: {str(e)}") from e 105 | -------------------------------------------------------------------------------- /src/mcp_text_editor/handlers/patch_text_file_contents.py: -------------------------------------------------------------------------------- 1 | """Handler for patching text file contents.""" 2 | 3 | import json 4 | import logging 5 | import os 6 | import traceback 7 | from typing import Any, Dict, Sequence 8 | 9 | from mcp.types import TextContent, Tool 10 | 11 | from mcp_text_editor.handlers.base import BaseHandler 12 | 13 | logger = logging.getLogger("mcp-text-editor") 14 | 15 | 16 | class PatchTextFileContentsHandler(BaseHandler): 17 | """Handler for patching a text file.""" 18 | 19 | name = "patch_text_file_contents" 20 | description = "Apply patches to text files with hash-based validation for concurrency control.you need to use get_text_file_contents tool to get the file hash and range hash every time before using this tool. you can use append_text_file_contents tool to append text contents to the file without range hash, start and end. you can use insert_text_file_contents tool to insert text contents to the file without range hash, start and end." 21 | 22 | def get_tool_description(self) -> Tool: 23 | """Get the tool description.""" 24 | return Tool( 25 | name=self.name, 26 | description=self.description, 27 | inputSchema={ 28 | "type": "object", 29 | "properties": { 30 | "file_path": { 31 | "type": "string", 32 | "description": "Path to the text file. File path must be absolute.", 33 | }, 34 | "file_hash": { 35 | "type": "string", 36 | "description": "Hash of the file contents for concurrency control.", 37 | }, 38 | "patches": { 39 | "type": "array", 40 | "description": "List of patches to apply", 41 | "items": { 42 | "type": "object", 43 | "properties": { 44 | "start": { 45 | "type": "integer", 46 | "description": "Starting line number (1-based).it should match the range hash.", 47 | }, 48 | "end": { 49 | "type": "integer", 50 | "description": "Ending line number (null for end of file).it should match the range hash.", 51 | }, 52 | "contents": { 53 | "type": "string", 54 | "description": "New content to replace the range with", 55 | }, 56 | "range_hash": { 57 | "type": "string", 58 | "description": "Hash of the content being replaced. it should get from get_text_file_contents tool with the same start and end.", 59 | }, 60 | }, 61 | "required": ["start", "end", "contents", "range_hash"], 62 | }, 63 | }, 64 | "encoding": { 65 | "type": "string", 66 | "description": "Text encoding (default: 'utf-8')", 67 | "default": "utf-8", 68 | }, 69 | }, 70 | "required": ["file_path", "file_hash", "patches"], 71 | }, 72 | ) 73 | 74 | async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]: 75 | """Execute the tool with given arguments.""" 76 | try: 77 | if "file_path" not in arguments: 78 | raise RuntimeError("Missing required argument: file_path") 79 | if "file_hash" not in arguments: 80 | raise RuntimeError("Missing required argument: file_hash") 81 | if "patches" not in arguments: 82 | raise RuntimeError("Missing required argument: patches") 83 | 84 | file_path = arguments["file_path"] 85 | if not os.path.isabs(file_path): 86 | raise RuntimeError(f"File path must be absolute: {file_path}") 87 | 88 | # Check if file exists 89 | if not os.path.exists(file_path): 90 | raise RuntimeError(f"File does not exist: {file_path}") 91 | 92 | encoding = arguments.get("encoding", "utf-8") 93 | 94 | # Apply patches using editor.edit_file_contents 95 | result = await self.editor.edit_file_contents( 96 | file_path=file_path, 97 | expected_file_hash=arguments["file_hash"], 98 | patches=arguments["patches"], 99 | encoding=encoding, 100 | ) 101 | 102 | return [TextContent(type="text", text=json.dumps(result, indent=2))] 103 | 104 | except Exception as e: 105 | logger.error(f"Error processing request: {str(e)}") 106 | logger.error(traceback.format_exc()) 107 | raise RuntimeError(f"Error processing request: {str(e)}") from e 108 | -------------------------------------------------------------------------------- /src/mcp_text_editor/models.py: -------------------------------------------------------------------------------- 1 | """Data models for the MCP Text Editor Server.""" 2 | 3 | from typing import Dict, List, Optional 4 | 5 | from pydantic import BaseModel, Field, field_validator, model_validator 6 | 7 | 8 | class GetTextFileContentsRequest(BaseModel): 9 | """Request model for getting text file contents.""" 10 | 11 | file_path: str = Field(..., description="Path to the text file") 12 | start: int = Field(1, description="Starting line number (1-based)") 13 | end: Optional[int] = Field(None, description="Ending line number (inclusive)") 14 | 15 | 16 | class GetTextFileContentsResponse(BaseModel): 17 | """Response model for getting text file contents.""" 18 | 19 | contents: str = Field(..., description="File contents") 20 | start: int = Field(..., description="Starting line number") 21 | end: int = Field(..., description="Ending line number") 22 | hash: str = Field(..., description="Hash of the contents") 23 | 24 | 25 | class EditPatch(BaseModel): 26 | """Model for a single edit patch operation.""" 27 | 28 | start: int = Field(1, description="Starting line for edit") 29 | end: Optional[int] = Field(None, description="Ending line for edit") 30 | contents: str = Field(..., description="New content to insert") 31 | range_hash: Optional[str] = Field( 32 | None, # None for new patches, must be explicitly set 33 | description="Hash of content being replaced. Empty string for insertions.", 34 | ) 35 | 36 | @model_validator(mode="after") 37 | def validate_range_hash(self) -> "EditPatch": 38 | """Validate that range_hash is set and handle end field validation.""" 39 | # range_hash must be explicitly set 40 | if self.range_hash is None: 41 | raise ValueError("range_hash is required") 42 | 43 | # For safety, convert None to the special range hash value 44 | if self.end is None and self.range_hash != "": 45 | # Special case: patch with end=None is allowed 46 | pass 47 | 48 | return self 49 | 50 | 51 | class EditFileOperation(BaseModel): 52 | """Model for individual file edit operation.""" 53 | 54 | path: str = Field(..., description="Path to the file") 55 | hash: str = Field(..., description="Hash of original contents") 56 | patches: List[EditPatch] = Field(..., description="Edit operations to apply") 57 | 58 | 59 | class EditResult(BaseModel): 60 | """Model for edit operation result.""" 61 | 62 | result: str = Field(..., description="Operation result (ok/error)") 63 | reason: Optional[str] = Field(None, description="Error message if applicable") 64 | hash: Optional[str] = Field( 65 | None, description="Current content hash (None for missing files)" 66 | ) 67 | 68 | @model_validator(mode="after") 69 | def validate_error_result(self) -> "EditResult": 70 | """Remove hash when result is error.""" 71 | if self.result == "error": 72 | object.__setattr__(self, "hash", None) 73 | return self 74 | 75 | def to_dict(self) -> Dict: 76 | """Convert EditResult to a dictionary.""" 77 | result = {"result": self.result} 78 | if self.reason is not None: 79 | result["reason"] = self.reason 80 | if self.hash is not None: 81 | result["hash"] = self.hash 82 | return result 83 | 84 | 85 | class EditTextFileContentsRequest(BaseModel): 86 | """Request model for editing text file contents. 87 | 88 | Example: 89 | { 90 | "files": [ 91 | { 92 | "path": "/path/to/file", 93 | "hash": "abc123...", 94 | "patches": [ 95 | { 96 | "start": 1, # default: 1 (top of file) 97 | "end": null, # default: null (end of file) 98 | "contents": "new content" 99 | } 100 | ] 101 | } 102 | ] 103 | } 104 | """ 105 | 106 | files: List[EditFileOperation] = Field(..., description="List of file operations") 107 | 108 | 109 | class FileRange(BaseModel): 110 | """Represents a line range in a file.""" 111 | 112 | start: int = Field(..., description="Starting line number (1-based)") 113 | end: Optional[int] = Field( 114 | None, description="Ending line number (null for end of file)" 115 | ) 116 | range_hash: Optional[str] = Field( 117 | None, description="Hash of the content to be deleted" 118 | ) 119 | 120 | 121 | class FileRanges(BaseModel): 122 | """Represents a file and its line ranges.""" 123 | 124 | file_path: str = Field(..., description="Path to the text file") 125 | ranges: List[FileRange] = Field( 126 | ..., description="List of line ranges to read from the file" 127 | ) 128 | 129 | 130 | class InsertTextFileContentsRequest(BaseModel): 131 | """Request model for inserting text into a file. 132 | 133 | Example: 134 | { 135 | "path": "/path/to/file", 136 | "file_hash": "abc123...", 137 | "after": 5, # Insert after line 5 138 | "contents": "new content" 139 | } 140 | or 141 | { 142 | "path": "/path/to/file", 143 | "file_hash": "abc123...", 144 | "before": 5, # Insert before line 5 145 | "contents": "new content" 146 | } 147 | """ 148 | 149 | path: str = Field(..., description="Path to the text file") 150 | file_hash: str = Field(..., description="Hash of original contents") 151 | after: Optional[int] = Field( 152 | None, description="Line number after which to insert content" 153 | ) 154 | before: Optional[int] = Field( 155 | None, description="Line number before which to insert content" 156 | ) 157 | encoding: Optional[str] = Field( 158 | "utf-8", description="Text encoding (default: 'utf-8')" 159 | ) 160 | contents: str = Field(..., description="Content to insert") 161 | 162 | @model_validator(mode="after") 163 | def validate_position(self) -> "InsertTextFileContentsRequest": 164 | """Validate that exactly one of after or before is specified.""" 165 | if (self.after is None and self.before is None) or ( 166 | self.after is not None and self.before is not None 167 | ): 168 | raise ValueError("Exactly one of 'after' or 'before' must be specified") 169 | return self 170 | 171 | @field_validator("after", "before") 172 | def validate_line_number(cls, v) -> Optional[int]: 173 | """Validate that line numbers are positive.""" 174 | if v is not None and v < 1: 175 | raise ValueError("Line numbers must be positive") 176 | return v 177 | 178 | 179 | class DeleteTextFileContentsRequest(BaseModel): 180 | """Request model for deleting text from a file. 181 | Example: 182 | { 183 | "file_path": "/path/to/file", 184 | "file_hash": "abc123...", 185 | "ranges": [ 186 | { 187 | "start": 5, 188 | "end": 10, 189 | "range_hash": "def456..." 190 | } 191 | ] 192 | } 193 | """ 194 | 195 | file_path: str = Field(..., description="Path to the text file") 196 | file_hash: str = Field(..., description="Hash of original contents") 197 | ranges: List[FileRange] = Field(..., description="List of ranges to delete") 198 | encoding: Optional[str] = Field( 199 | "utf-8", description="Text encoding (default: 'utf-8')" 200 | ) 201 | 202 | 203 | class PatchTextFileContentsRequest(BaseModel): 204 | """Request model for patching text in a file. 205 | Example: 206 | { 207 | "file_path": "/path/to/file", 208 | "file_hash": "abc123...", 209 | "patches": [ 210 | { 211 | "start": 5, 212 | "end": 10, 213 | "contents": "new content", 214 | "range_hash": "def456..." 215 | } 216 | ] 217 | } 218 | """ 219 | 220 | file_path: str = Field(..., description="Path to the text file") 221 | file_hash: str = Field(..., description="Hash of original contents") 222 | patches: List[EditPatch] = Field(..., description="List of patches to apply") 223 | encoding: Optional[str] = Field( 224 | "utf-8", description="Text encoding (default: 'utf-8')" 225 | ) 226 | -------------------------------------------------------------------------------- /src/mcp_text_editor/server.py: -------------------------------------------------------------------------------- 1 | """MCP Text Editor Server implementation.""" 2 | 3 | import asyncio 4 | import logging 5 | import traceback 6 | from collections.abc import Sequence 7 | from typing import Any, List 8 | 9 | from mcp.server import Server 10 | from mcp.types import TextContent, Tool 11 | 12 | from mcp_text_editor.handlers import ( 13 | AppendTextFileContentsHandler, 14 | CreateTextFileHandler, 15 | DeleteTextFileContentsHandler, 16 | GetTextFileContentsHandler, 17 | InsertTextFileContentsHandler, 18 | PatchTextFileContentsHandler, 19 | ) 20 | from mcp_text_editor.version import __version__ 21 | 22 | # Configure logging 23 | logging.basicConfig(level=logging.INFO) 24 | logger = logging.getLogger("mcp-text-editor") 25 | 26 | app: Server = Server("mcp-text-editor") 27 | 28 | # Initialize tool handlers 29 | get_contents_handler = GetTextFileContentsHandler() 30 | patch_file_handler = PatchTextFileContentsHandler() 31 | create_file_handler = CreateTextFileHandler() 32 | append_file_handler = AppendTextFileContentsHandler() 33 | delete_contents_handler = DeleteTextFileContentsHandler() 34 | insert_file_handler = InsertTextFileContentsHandler() 35 | 36 | 37 | @app.list_tools() 38 | async def list_tools() -> List[Tool]: 39 | """List available tools.""" 40 | return [ 41 | get_contents_handler.get_tool_description(), 42 | create_file_handler.get_tool_description(), 43 | append_file_handler.get_tool_description(), 44 | delete_contents_handler.get_tool_description(), 45 | insert_file_handler.get_tool_description(), 46 | patch_file_handler.get_tool_description(), 47 | ] 48 | 49 | 50 | @app.call_tool() 51 | async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]: 52 | """Handle tool calls.""" 53 | logger.info(f"Calling tool: {name}") 54 | try: 55 | if name == get_contents_handler.name: 56 | return await get_contents_handler.run_tool(arguments) 57 | elif name == create_file_handler.name: 58 | return await create_file_handler.run_tool(arguments) 59 | elif name == append_file_handler.name: 60 | return await append_file_handler.run_tool(arguments) 61 | elif name == delete_contents_handler.name: 62 | return await delete_contents_handler.run_tool(arguments) 63 | elif name == insert_file_handler.name: 64 | return await insert_file_handler.run_tool(arguments) 65 | elif name == patch_file_handler.name: 66 | return await patch_file_handler.run_tool(arguments) 67 | else: 68 | raise ValueError(f"Unknown tool: {name}") 69 | except ValueError: 70 | logger.error(traceback.format_exc()) 71 | raise 72 | except Exception as e: 73 | logger.error(traceback.format_exc()) 74 | raise RuntimeError(f"Error executing command: {str(e)}") from e 75 | 76 | 77 | async def main() -> None: 78 | """Main entry point for the MCP text editor server.""" 79 | logger.info(f"Starting MCP text editor server v{__version__}") 80 | try: 81 | from mcp.server.stdio import stdio_server 82 | 83 | async with stdio_server() as (read_stream, write_stream): 84 | await app.run( 85 | read_stream, 86 | write_stream, 87 | app.create_initialization_options(), 88 | ) 89 | except Exception as e: 90 | logger.error(f"Server error: {str(e)}") 91 | raise 92 | 93 | 94 | if __name__ == "__main__": 95 | asyncio.run(main()) 96 | -------------------------------------------------------------------------------- /src/mcp_text_editor/service.py: -------------------------------------------------------------------------------- 1 | """Core service logic for the MCP Text Editor Server.""" 2 | 3 | import hashlib 4 | from typing import Dict, List, Optional, Tuple 5 | 6 | from mcp_text_editor.models import ( 7 | DeleteTextFileContentsRequest, 8 | EditFileOperation, 9 | EditPatch, 10 | EditResult, 11 | FileRange, 12 | ) 13 | 14 | 15 | class TextEditorService: 16 | """Service class for text file operations.""" 17 | 18 | @staticmethod 19 | def calculate_hash(content: str) -> str: 20 | """Calculate SHA-256 hash of content.""" 21 | return hashlib.sha256(content.encode()).hexdigest() 22 | 23 | @staticmethod 24 | def read_file_contents( 25 | file_path: str, start: int = 1, end: Optional[int] = None 26 | ) -> Tuple[str, int, int]: 27 | """Read file contents within specified line range.""" 28 | with open(file_path, "r", encoding="utf-8") as f: 29 | lines = f.readlines() 30 | 31 | # Adjust line numbers to 0-based index 32 | start = max(1, start) - 1 33 | end = len(lines) if end is None else min(end, len(lines)) 34 | 35 | selected_lines = lines[start:end] 36 | content = "".join(selected_lines) 37 | 38 | return content, start + 1, end 39 | 40 | @staticmethod 41 | def validate_patches(patches: List[EditPatch], total_lines: int) -> bool: 42 | """Validate patches for overlaps and bounds.""" 43 | # Sort patches by start 44 | sorted_patches = sorted(patches, key=lambda x: x.start) 45 | 46 | prev_end = 0 47 | for patch in sorted_patches: 48 | if patch.start <= prev_end: 49 | return False 50 | patch_end = patch.end or total_lines 51 | if patch_end > total_lines: 52 | return False 53 | prev_end = patch_end 54 | 55 | return True 56 | 57 | def edit_file_contents( 58 | self, file_path: str, operation: EditFileOperation 59 | ) -> Dict[str, EditResult]: 60 | """Edit file contents with conflict detection.""" 61 | current_hash = None 62 | try: 63 | with open(file_path, "r", encoding="utf-8") as f: 64 | current_content = f.read() 65 | current_hash = self.calculate_hash(current_content) 66 | 67 | # Check for conflicts 68 | if current_hash != operation.hash: 69 | return { 70 | file_path: EditResult( 71 | result="error", 72 | reason="Content hash mismatch", 73 | hash=current_hash, 74 | ) 75 | } 76 | 77 | # Split content into lines 78 | lines = current_content.splitlines(keepends=True) 79 | 80 | # Validate patches 81 | if not self.validate_patches(operation.patches, len(lines)): 82 | return { 83 | file_path: EditResult( 84 | result="error", 85 | reason="Invalid patch ranges", 86 | hash=current_hash, 87 | ) 88 | } 89 | 90 | # Apply patches 91 | new_lines = lines.copy() 92 | for patch in operation.patches: 93 | start_idx = patch.start - 1 94 | end_idx = patch.end if patch.end else len(lines) 95 | patch_lines = patch.contents.splitlines(keepends=True) 96 | new_lines[start_idx:end_idx] = patch_lines 97 | 98 | # Write new content 99 | new_content = "".join(new_lines) 100 | with open(file_path, "w", encoding="utf-8") as f: 101 | f.write(new_content) 102 | 103 | new_hash = self.calculate_hash(new_content) 104 | return { 105 | file_path: EditResult( 106 | result="ok", 107 | hash=new_hash, 108 | reason=None, 109 | ) 110 | } 111 | 112 | except FileNotFoundError as e: 113 | return { 114 | file_path: EditResult( 115 | result="error", 116 | reason=str(e), 117 | hash=None, 118 | ) 119 | } 120 | except Exception as e: 121 | return { 122 | file_path: EditResult( 123 | result="error", 124 | reason=str(e), 125 | hash=None, # Don't return the current hash on error 126 | ) 127 | } 128 | 129 | def delete_text_file_contents( 130 | self, 131 | request: DeleteTextFileContentsRequest, 132 | ) -> Dict[str, EditResult]: 133 | """Delete specified ranges from a text file with conflict detection.""" 134 | current_hash = None 135 | try: 136 | with open(request.file_path, "r", encoding=request.encoding) as f: 137 | current_content = f.read() 138 | current_hash = self.calculate_hash(current_content) 139 | 140 | # Check for conflicts 141 | if current_hash != request.file_hash: 142 | return { 143 | request.file_path: EditResult( 144 | result="error", 145 | reason="Content hash mismatch", 146 | hash=current_hash, 147 | ) 148 | } 149 | 150 | # Split content into lines 151 | lines = current_content.splitlines(keepends=True) 152 | 153 | # Validate ranges 154 | if not request.ranges: # Check for empty ranges list 155 | return { 156 | request.file_path: EditResult( 157 | result="error", 158 | reason="Missing required argument: ranges", 159 | hash=current_hash, 160 | ) 161 | } 162 | 163 | if not self.validate_ranges(request.ranges, len(lines)): 164 | return { 165 | request.file_path: EditResult( 166 | result="error", 167 | reason="Invalid ranges", 168 | hash=current_hash, 169 | ) 170 | } 171 | 172 | # Delete ranges in reverse order to handle line number shifts 173 | new_lines = lines.copy() 174 | sorted_ranges = sorted(request.ranges, key=lambda x: x.start, reverse=True) 175 | for range_ in sorted_ranges: 176 | start_idx = range_.start - 1 177 | end_idx = range_.end if range_.end else len(lines) 178 | target_content = "".join(lines[start_idx:end_idx]) 179 | target_hash = self.calculate_hash(target_content) 180 | if target_hash != range_.range_hash: 181 | return { 182 | request.file_path: EditResult( 183 | result="error", 184 | reason=f"Content hash mismatch for range {range_.start}-{range_.end}", 185 | hash=current_hash, 186 | ) 187 | } 188 | del new_lines[start_idx:end_idx] 189 | 190 | # Write new content 191 | new_content = "".join(new_lines) 192 | with open(request.file_path, "w", encoding=request.encoding) as f: 193 | f.write(new_content) 194 | 195 | new_hash = self.calculate_hash(new_content) 196 | return { 197 | request.file_path: EditResult( 198 | result="ok", 199 | hash=new_hash, 200 | reason=None, 201 | ) 202 | } 203 | 204 | except FileNotFoundError as e: 205 | return { 206 | request.file_path: EditResult( 207 | result="error", 208 | reason=str(e), 209 | hash=None, 210 | ) 211 | } 212 | except Exception as e: 213 | return { 214 | request.file_path: EditResult( 215 | result="error", 216 | reason=f"Error deleting contents: {str(e)}", 217 | hash=None, 218 | ) 219 | } 220 | 221 | @staticmethod 222 | def validate_ranges(ranges: List[FileRange], total_lines: int) -> bool: 223 | """Validate ranges for overlaps and bounds.""" 224 | # Sort ranges by start line 225 | sorted_ranges = sorted(ranges, key=lambda x: x.start) 226 | 227 | prev_end = 0 228 | for range_ in sorted_ranges: 229 | if range_.start <= prev_end: 230 | return False # Overlapping ranges 231 | if range_.start < 1: 232 | return False # Invalid start line 233 | range_end = range_.end or total_lines 234 | if range_end > total_lines: 235 | return False # Exceeding file length 236 | if range_.end is not None and range_.end < range_.start: 237 | return False # End before start 238 | prev_end = range_end 239 | 240 | return True 241 | -------------------------------------------------------------------------------- /src/mcp_text_editor/text_editor.py: -------------------------------------------------------------------------------- 1 | """Core text editor functionality with file operation handling.""" 2 | 3 | import hashlib 4 | import logging 5 | import os 6 | from typing import Any, Dict, List, Optional, Tuple 7 | 8 | from mcp_text_editor.models import DeleteTextFileContentsRequest, EditPatch, FileRanges 9 | from mcp_text_editor.service import TextEditorService 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class TextEditor: 15 | """Handles text file operations with security checks and conflict detection.""" 16 | 17 | def __init__(self): 18 | """Initialize TextEditor.""" 19 | self._validate_environment() 20 | self.service = TextEditorService() 21 | 22 | def create_error_response( 23 | self, 24 | error_message: str, 25 | content_hash: Optional[str] = None, 26 | file_path: Optional[str] = None, 27 | suggestion: Optional[str] = None, 28 | hint: Optional[str] = None, 29 | ) -> Dict[str, Any]: 30 | """Create a standardized error response. 31 | 32 | Args: 33 | error_message (str): The error message to include 34 | content_hash (Optional[str], optional): Hash of the current content if available 35 | file_path (Optional[str], optional): File path to use as dictionary key 36 | suggestion (Optional[str], optional): Suggested operation type 37 | hint (Optional[str], optional): Hint message for users 38 | 39 | Returns: 40 | Dict[str, Any]: Standardized error response structure 41 | """ 42 | error_response = { 43 | "result": "error", 44 | "reason": error_message, 45 | "file_hash": content_hash, 46 | } 47 | 48 | # Add fields if provided 49 | if content_hash is not None: 50 | error_response["file_hash"] = content_hash 51 | if suggestion: 52 | error_response["suggestion"] = suggestion 53 | if hint: 54 | error_response["hint"] = hint 55 | 56 | if file_path: 57 | return {file_path: error_response} 58 | return error_response 59 | 60 | def _validate_environment(self) -> None: 61 | """ 62 | Validate environment variables and setup. 63 | Can be extended to check for specific permissions or configurations. 64 | """ 65 | # Future: Add environment validation if needed 66 | pass # pragma: no cover 67 | 68 | def _validate_file_path(self, file_path: str | os.PathLike) -> None: 69 | """ 70 | Validate if file path is allowed and secure. 71 | 72 | Args: 73 | file_path (str | os.PathLike): Path to validate 74 | 75 | Raises: 76 | ValueError: If path is not allowed or contains dangerous patterns 77 | """ 78 | # Convert path to string for checking 79 | path_str = str(file_path) 80 | 81 | # Check for dangerous patterns 82 | if ".." in path_str: 83 | raise ValueError("Path traversal not allowed") 84 | 85 | @staticmethod 86 | def calculate_hash(content: str) -> str: 87 | """ 88 | Calculate SHA-256 hash of content. 89 | 90 | Args: 91 | content (str): Content to hash 92 | 93 | Returns: 94 | str: Hex digest of SHA-256 hash 95 | """ 96 | return hashlib.sha256(content.encode()).hexdigest() 97 | 98 | async def _read_file( 99 | self, file_path: str, encoding: str = "utf-8" 100 | ) -> Tuple[List[str], str, int]: 101 | """Read file and return lines, content, and total lines. 102 | 103 | Args: 104 | file_path (str): Path to the file to read 105 | encoding (str, optional): File encoding. Defaults to "utf-8" 106 | 107 | Returns: 108 | Tuple[List[str], str, int]: Lines, content, and total line count 109 | 110 | Raises: 111 | FileNotFoundError: If file not found 112 | UnicodeDecodeError: If file cannot be decoded with specified encoding 113 | """ 114 | self._validate_file_path(file_path) 115 | try: 116 | with open(file_path, "r", encoding=encoding) as f: 117 | lines = f.readlines() 118 | file_content = "".join(lines) 119 | return lines, file_content, len(lines) 120 | except FileNotFoundError as err: 121 | raise FileNotFoundError(f"File not found: {file_path}") from err 122 | except UnicodeDecodeError as err: 123 | raise UnicodeDecodeError( 124 | encoding, 125 | err.object, 126 | err.start, 127 | err.end, 128 | f"Failed to decode file '{file_path}' with {encoding} encoding", 129 | ) from err 130 | 131 | async def read_multiple_ranges( 132 | self, ranges: List[Dict[str, Any]], encoding: str = "utf-8" 133 | ) -> Dict[str, Dict[str, Any]]: 134 | result: Dict[str, Dict[str, Any]] = {} 135 | 136 | for file_range_dict in ranges: 137 | file_range = FileRanges.model_validate(file_range_dict) 138 | file_path = file_range.file_path 139 | lines, file_content, total_lines = await self._read_file( 140 | file_path, encoding=encoding 141 | ) 142 | file_hash = self.calculate_hash(file_content) 143 | result[file_path] = {"ranges": [], "file_hash": file_hash} 144 | 145 | for range_spec in file_range.ranges: 146 | start = max(1, range_spec.start) - 1 147 | end_value = range_spec.end 148 | end = ( 149 | min(total_lines, end_value) 150 | if end_value is not None 151 | else total_lines 152 | ) 153 | 154 | if start >= total_lines: 155 | empty_content = "" 156 | result[file_path]["ranges"].append( 157 | { 158 | "content": empty_content, 159 | "start": start + 1, 160 | "end": start + 1, 161 | "range_hash": self.calculate_hash(empty_content), 162 | "total_lines": total_lines, 163 | "content_size": 0, 164 | } 165 | ) 166 | continue 167 | 168 | selected_lines = lines[start:end] 169 | content = "".join(selected_lines) 170 | range_hash = self.calculate_hash(content) 171 | 172 | result[file_path]["ranges"].append( 173 | { 174 | "content": content, 175 | "start": start + 1, 176 | "end": end, 177 | "range_hash": range_hash, 178 | "total_lines": total_lines, 179 | "content_size": len(content), 180 | } 181 | ) 182 | 183 | return result 184 | 185 | async def read_file_contents( 186 | self, 187 | file_path: str, 188 | start: int = 1, 189 | end: Optional[int] = None, 190 | encoding: str = "utf-8", 191 | ) -> Tuple[str, int, int, str, int, int]: 192 | lines, file_content, total_lines = await self._read_file( 193 | file_path, encoding=encoding 194 | ) 195 | 196 | if end is not None and end < start: 197 | raise ValueError("End line must be greater than or equal to start line") 198 | 199 | start = max(1, start) - 1 200 | end = total_lines if end is None else min(end, total_lines) 201 | 202 | if start >= total_lines: 203 | empty_content = "" 204 | empty_hash = self.calculate_hash(empty_content) 205 | return empty_content, start, start, empty_hash, total_lines, 0 206 | if end < start: 207 | raise ValueError("End line must be greater than or equal to start line") 208 | 209 | selected_lines = lines[start:end] 210 | content = "".join(selected_lines) 211 | content_hash = self.calculate_hash(content) 212 | content_size = len(content.encode(encoding)) 213 | 214 | return ( 215 | content, 216 | start + 1, 217 | end, 218 | content_hash, 219 | total_lines, 220 | content_size, 221 | ) 222 | 223 | async def edit_file_contents( 224 | self, 225 | file_path: str, 226 | expected_file_hash: str, 227 | patches: List[Dict[str, Any]], 228 | encoding: str = "utf-8", 229 | ) -> Dict[str, Any]: 230 | """ 231 | Edit file contents with hash-based conflict detection and multiple patches. 232 | 233 | Args: 234 | file_path (str): Path to the file to edit 235 | expected_hash (str): Expected hash of the file before editing 236 | patches (List[Dict[str, Any]]): List of patches to apply, each containing: 237 | - start (int): Starting line number (1-based) 238 | - end (Optional[int]): Ending line number (inclusive) 239 | - contents (str): New content to insert (if empty string, consider using delete_text_file_contents instead) 240 | - range_hash (str): Expected hash of the content being replaced 241 | 242 | Returns: 243 | Dict[str, Any]: Results of the operation containing: 244 | - result: "ok" or "error" 245 | - hash: New file hash if successful, None if error 246 | - reason: Error message if result is "error" 247 | "file_hash": None, 248 | } 249 | 250 | # Read current file content and verify hash 251 | """ 252 | self._validate_file_path(file_path) 253 | try: 254 | if not os.path.exists(file_path): 255 | if expected_file_hash not in ["", None]: # Allow null hash 256 | return self.create_error_response( 257 | "File not found and non-empty hash provided", 258 | suggestion="append", 259 | hint="For new files, please consider using append_text_file_contents", 260 | ) 261 | # Create parent directories if they don't exist 262 | parent_dir = os.path.dirname(file_path) 263 | if parent_dir: 264 | try: 265 | os.makedirs(parent_dir, exist_ok=True) 266 | except OSError as e: 267 | return self.create_error_response( 268 | f"Failed to create directory: {str(e)}", 269 | suggestion="patch", 270 | hint="Please check file permissions and try again", 271 | ) 272 | # Initialize empty state for new file 273 | current_file_content = "" 274 | current_file_hash = "" 275 | lines: List[str] = [] 276 | encoding = "utf-8" 277 | else: 278 | # Read current file content and verify hash 279 | ( 280 | current_file_content, 281 | _, 282 | _, 283 | current_file_hash, 284 | total_lines, 285 | _, 286 | ) = await self.read_file_contents(file_path, encoding=encoding) 287 | 288 | # Treat empty file as new file 289 | if not current_file_content: 290 | current_file_content = "" 291 | current_file_hash = "" 292 | lines = [] 293 | elif current_file_content and expected_file_hash == "": 294 | return self.create_error_response( 295 | "Unexpected error - Cannot treat existing file as new", 296 | ) 297 | elif current_file_hash != expected_file_hash: 298 | suggestion = "patch" 299 | hint = "Please use get_text_file_contents tool to get the current content and hash" 300 | 301 | return self.create_error_response( 302 | "FileHash mismatch - Please use get_text_file_contents tool to get current content and hashes, then retry with the updated hashes.", 303 | suggestion=suggestion, 304 | hint=hint, 305 | ) 306 | else: 307 | lines = current_file_content.splitlines(keepends=True) 308 | lines = current_file_content.splitlines(keepends=True) 309 | 310 | # Convert patches to EditPatch objects 311 | patch_objects = [EditPatch.model_validate(p) for p in patches] 312 | 313 | # Sort patches from bottom to top to avoid line number shifts 314 | sorted_patches = sorted( 315 | patch_objects, 316 | key=lambda x: ( 317 | -(x.start), 318 | -(x.end or x.start or float("inf")), 319 | ), 320 | ) 321 | 322 | # Check for overlapping patches 323 | for i in range(len(sorted_patches)): 324 | for j in range(i + 1, len(sorted_patches)): 325 | patch1 = sorted_patches[i] 326 | patch2 = sorted_patches[j] 327 | start1 = patch1.start 328 | end1 = patch1.end or start1 329 | start2 = patch2.start 330 | end2 = patch2.end or start2 331 | 332 | if (start1 <= end2 and end1 >= start2) or ( 333 | start2 <= end1 and end2 >= start1 334 | ): 335 | return self.create_error_response( 336 | "Overlapping patches detected", 337 | suggestion="patch", 338 | hint="Please ensure your patches do not overlap", 339 | ) 340 | 341 | # Apply patches 342 | for patch in sorted_patches: 343 | # Get line numbers (1-based) 344 | start: int 345 | end: Optional[int] 346 | if isinstance(patch, EditPatch): 347 | start = patch.start 348 | end = patch.end 349 | else: 350 | start = patch["start"] if "start" in patch else 1 351 | end = patch["end"] if "end" in patch else start 352 | 353 | # Check for invalid line range 354 | if end is not None and end < start: 355 | return { 356 | "result": "error", 357 | "reason": "End line must be greater than or equal to start line", 358 | "file_hash": None, 359 | "content": current_file_content, 360 | } 361 | 362 | # Handle unexpected empty hash for existing file 363 | if ( 364 | os.path.exists(file_path) 365 | and current_file_content 366 | and expected_file_hash == "" 367 | ): 368 | return { 369 | "result": "error", 370 | "reason": "File hash validation required: Empty hash provided for existing file", 371 | "details": { 372 | "file_path": file_path, 373 | "current_file_hash": self.calculate_hash( 374 | current_file_content 375 | ), 376 | "expected_file_hash": expected_file_hash, 377 | }, 378 | } 379 | 380 | # Calculate line ranges for zero-based indexing 381 | start_zero = start - 1 382 | 383 | # Get expected hash for validation 384 | expected_range_hash = None 385 | if isinstance(patch, dict): 386 | expected_range_hash = patch.get("range_hash") 387 | else: 388 | # For EditPatch objects, use model fields 389 | expected_range_hash = patch.range_hash 390 | 391 | # Determine operation type and validate hash requirements 392 | if not os.path.exists(file_path) or not current_file_content: 393 | # New file or empty file - treat as insertion 394 | is_insertion = True 395 | elif start_zero >= len(lines): 396 | is_insertion = True 397 | else: 398 | # For modification mode, check the range_hash 399 | is_insertion = expected_range_hash == "" 400 | if not is_insertion: 401 | # Calculate end_zero for content validation 402 | end_zero = ( 403 | len(lines) - 1 404 | if end is None 405 | else min(end - 1, len(lines) - 1) 406 | ) 407 | 408 | # Hash provided - verify content 409 | target_lines = lines[start_zero : end_zero + 1] 410 | target_content = "".join(target_lines) 411 | actual_range_hash = self.calculate_hash(target_content) 412 | 413 | if actual_range_hash != expected_range_hash: 414 | return { 415 | "result": "error", 416 | "reason": "Content range hash mismatch - Please use get_text_file_contents tool with the same start and end to get current content and hashes, then retry with the updated hashes.", 417 | "suggestion": "get", 418 | "hint": "Please run get_text_file_contents first to get current content and hashes", 419 | } 420 | 421 | # Prepare new content 422 | if isinstance(patch, EditPatch): 423 | contents = patch.contents 424 | else: 425 | contents = patch["contents"] 426 | 427 | # Check if this is a deletion (empty content) 428 | if not contents.strip(): 429 | return { 430 | "result": "ok", 431 | "file_hash": current_file_hash, # Return current hash since no changes made 432 | "hint": "For content deletion, please consider using delete_text_file_contents instead of patch with empty content", 433 | "suggestion": "delete", 434 | } 435 | 436 | # Set suggestions for alternative tools 437 | suggestion_text: Optional[str] = None 438 | hint_text: Optional[str] = None 439 | if not os.path.exists(file_path) or not current_file_content: 440 | suggestion_text = "append" 441 | hint_text = "For new or empty files, please consider using append_text_file_contents instead" 442 | elif is_insertion: 443 | if start_zero >= len(lines): 444 | suggestion_text = "append" 445 | hint_text = "For adding content at the end of file, please consider using append_text_file_contents instead" 446 | else: 447 | suggestion_text = "insert" 448 | hint_text = "For inserting content within file, please consider using insert_text_file_contents instead" 449 | 450 | # Prepare the content 451 | new_content = contents if contents.endswith("\n") else contents + "\n" 452 | new_lines = new_content.splitlines(keepends=True) 453 | 454 | # For insertion mode, we don't need end_zero 455 | if is_insertion: 456 | # Insert at the specified line 457 | lines[start_zero:start_zero] = new_lines 458 | else: 459 | # We already have end_zero for replacements 460 | lines[start_zero : end_zero + 1] = new_lines 461 | 462 | # Write the final content back to file 463 | final_content = "".join(lines) 464 | with open(file_path, "w", encoding=encoding) as f: 465 | f.write(final_content) 466 | 467 | # Calculate new hash 468 | new_hash = self.calculate_hash(final_content) 469 | 470 | return { 471 | "result": "ok", 472 | "file_hash": new_hash, 473 | "reason": None, 474 | "suggestion": suggestion_text, 475 | "hint": hint_text, 476 | } 477 | 478 | except FileNotFoundError: 479 | return self.create_error_response( 480 | f"File not found: {file_path}", 481 | suggestion="append", 482 | hint="For new files, please use append_text_file_contents", 483 | ) 484 | except (IOError, UnicodeError, PermissionError) as e: 485 | return self.create_error_response( 486 | f"Error editing file: {str(e)}", 487 | suggestion="patch", 488 | hint="Please check file permissions and try again", 489 | ) 490 | except Exception as e: 491 | import traceback 492 | 493 | logger.error(f"Error: {str(e)}") 494 | logger.error(f"Traceback:\n{traceback.format_exc()}") 495 | return self.create_error_response( 496 | f"Error: {str(e)}", 497 | suggestion="patch", 498 | hint="Please try again or report the issue if it persists", 499 | ) 500 | 501 | async def insert_text_file_contents( 502 | self, 503 | file_path: str, 504 | file_hash: str, 505 | contents: str, 506 | after: Optional[int] = None, 507 | before: Optional[int] = None, 508 | encoding: str = "utf-8", 509 | ) -> Dict[str, Any]: 510 | """Insert text content before or after a specific line in a file. 511 | 512 | Args: 513 | file_path (str): Path to the file to edit 514 | file_hash (str): Expected hash of the file before editing 515 | contents (str): Content to insert 516 | after (Optional[int]): Line number after which to insert content 517 | before (Optional[int]): Line number before which to insert content 518 | encoding (str, optional): File encoding. Defaults to "utf-8" 519 | 520 | Returns: 521 | Dict[str, Any]: Results containing: 522 | - result: "ok" or "error" 523 | - hash: New file hash if successful 524 | - reason: Error message if result is "error" 525 | """ 526 | if (after is None and before is None) or ( 527 | after is not None and before is not None 528 | ): 529 | return { 530 | "result": "error", 531 | "reason": "Exactly one of 'after' or 'before' must be specified", 532 | "hash": None, 533 | } 534 | 535 | try: 536 | ( 537 | current_content, 538 | _, 539 | _, 540 | current_hash, 541 | total_lines, 542 | _, 543 | ) = await self.read_file_contents( 544 | file_path, 545 | encoding=encoding, 546 | ) 547 | 548 | if current_hash != file_hash: 549 | return { 550 | "result": "error", 551 | "reason": "File hash mismatch - Please use get_text_file_contents tool to get current content and hash", 552 | "hash": None, 553 | } 554 | 555 | # Split into lines, preserving line endings 556 | lines = current_content.splitlines(keepends=True) 557 | 558 | # Determine insertion point 559 | if after is not None: 560 | if after > total_lines: 561 | return { 562 | "result": "error", 563 | "reason": f"Line number {after} is beyond end of file (total lines: {total_lines})", 564 | "hash": None, 565 | } 566 | insert_pos = after 567 | else: # before must be set due to earlier validation 568 | assert before is not None 569 | if before > total_lines + 1: 570 | return { 571 | "result": "error", 572 | "reason": f"Line number {before} is beyond end of file (total lines: {total_lines})", 573 | "hash": None, 574 | } 575 | insert_pos = before - 1 576 | 577 | # Ensure content ends with newline 578 | if not contents.endswith("\n"): 579 | contents += "\n" 580 | 581 | # Insert the content 582 | lines.insert(insert_pos, contents) 583 | 584 | # Join lines and write back to file 585 | final_content = "".join(lines) 586 | with open(file_path, "w", encoding=encoding) as f: 587 | f.write(final_content) 588 | 589 | # Calculate new hash 590 | new_hash = self.calculate_hash(final_content) 591 | 592 | return { 593 | "result": "ok", 594 | "hash": new_hash, 595 | "reason": None, 596 | } 597 | 598 | except FileNotFoundError: 599 | return { 600 | "result": "error", 601 | "reason": f"File not found: {file_path}", 602 | "hash": None, 603 | } 604 | except Exception as e: 605 | return { 606 | "result": "error", 607 | "reason": str(e), 608 | "hash": None, 609 | } 610 | 611 | async def delete_text_file_contents( 612 | self, 613 | request: DeleteTextFileContentsRequest, 614 | ) -> Dict[str, Any]: 615 | """Delete specified ranges from a text file with conflict detection. 616 | 617 | Args: 618 | request (DeleteTextFileContentsRequest): The request containing: 619 | - file_path: Path to the text file 620 | - file_hash: Expected hash of the file before editing 621 | - ranges: List of ranges to delete 622 | - encoding: Optional text encoding (default: utf-8) 623 | 624 | Returns: 625 | Dict[str, Any]: Results containing: 626 | - result: "ok" or "error" 627 | - hash: New file hash if successful 628 | - reason: Error message if result is "error" 629 | """ 630 | self._validate_file_path(request.file_path) 631 | 632 | try: 633 | ( 634 | current_content, 635 | _, 636 | _, 637 | current_hash, 638 | total_lines, 639 | _, 640 | ) = await self.read_file_contents( 641 | request.file_path, 642 | encoding=request.encoding or "utf-8", 643 | ) 644 | 645 | # Check for conflicts 646 | if current_hash != request.file_hash: 647 | return { 648 | request.file_path: { 649 | "result": "error", 650 | "reason": "File hash mismatch - Please use get_text_file_contents tool to get current content and hash", 651 | "hash": current_hash, 652 | } 653 | } 654 | 655 | # Split content into lines 656 | lines = current_content.splitlines(keepends=True) 657 | 658 | # Sort ranges in reverse order to handle line number shifts 659 | sorted_ranges = sorted( 660 | request.ranges, 661 | key=lambda x: (x.start, x.end or float("inf")), 662 | reverse=True, 663 | ) 664 | 665 | # Validate ranges 666 | for i, range_ in enumerate(sorted_ranges): 667 | if range_.start < 1: 668 | return { 669 | request.file_path: { 670 | "result": "error", 671 | "reason": f"Invalid start line {range_.start}", 672 | "hash": current_hash, 673 | } 674 | } 675 | 676 | if range_.end and range_.end < range_.start: 677 | return { 678 | request.file_path: { 679 | "result": "error", 680 | "reason": f"End line {range_.end} is less than start line {range_.start}", 681 | "hash": current_hash, 682 | } 683 | } 684 | 685 | if range_.start > total_lines: 686 | return { 687 | request.file_path: { 688 | "result": "error", 689 | "reason": f"Start line {range_.start} exceeds file length {total_lines}", 690 | "hash": current_hash, 691 | } 692 | } 693 | 694 | end = range_.end or total_lines 695 | if end > total_lines: 696 | return { 697 | request.file_path: { 698 | "result": "error", 699 | "reason": f"End line {end} exceeds file length {total_lines}", 700 | "hash": current_hash, 701 | } 702 | } 703 | 704 | # Check for overlaps with next range 705 | if i + 1 < len(sorted_ranges): 706 | next_range = sorted_ranges[i + 1] 707 | next_end = next_range.end or total_lines 708 | if next_end >= range_.start: 709 | return { 710 | request.file_path: { 711 | "result": "error", 712 | "reason": "Overlapping ranges detected", 713 | "hash": current_hash, 714 | } 715 | } 716 | 717 | # Apply deletions 718 | for range_ in sorted_ranges: 719 | start_idx = range_.start - 1 720 | end_idx = range_.end if range_.end else len(lines) 721 | 722 | # Verify range content hash 723 | range_content = "".join(lines[start_idx:end_idx]) 724 | if self.calculate_hash(range_content) != range_.range_hash: 725 | return { 726 | request.file_path: { 727 | "result": "error", 728 | "reason": f"Content hash mismatch for range {range_.start}-{range_.end}", 729 | "hash": current_hash, 730 | } 731 | } 732 | 733 | del lines[start_idx:end_idx] 734 | 735 | # Write the final content back to file 736 | final_content = "".join(lines) 737 | with open(request.file_path, "w", encoding=request.encoding) as f: 738 | f.write(final_content) 739 | 740 | # Calculate new hash 741 | new_hash = self.calculate_hash(final_content) 742 | 743 | return { 744 | request.file_path: { 745 | "result": "ok", 746 | "hash": new_hash, 747 | "reason": None, 748 | } 749 | } 750 | 751 | except FileNotFoundError: 752 | return { 753 | request.file_path: { 754 | "result": "error", 755 | "reason": f"File not found: {request.file_path}", 756 | "hash": None, 757 | } 758 | } 759 | except Exception as e: 760 | return { 761 | request.file_path: { 762 | "result": "error", 763 | "reason": str(e), 764 | "hash": None, 765 | } 766 | } 767 | -------------------------------------------------------------------------------- /src/mcp_text_editor/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.0-dev" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Test configuration and fixtures.""" 2 | 3 | import os 4 | import tempfile 5 | from typing import AsyncGenerator, Generator 6 | 7 | import pytest 8 | import pytest_asyncio 9 | from mcp.server import Server 10 | 11 | from mcp_text_editor.server import app 12 | 13 | 14 | @pytest.fixture 15 | def test_file() -> Generator[str, None, None]: 16 | """Create a temporary test file.""" 17 | content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n" 18 | with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: 19 | f.write(content) 20 | file_path = f.name 21 | 22 | yield file_path 23 | 24 | # Cleanup 25 | if os.path.exists(file_path): 26 | os.unlink(file_path) 27 | 28 | 29 | @pytest.fixture 30 | def test_file_sjis() -> Generator[str, None, None]: 31 | """Create a temporary test file with Shift-JIS encoding.""" 32 | # test1, test2, test3 in Japanese encoded in Shift-JIS 33 | content = b"\x83\x65\x83\x58\x83\x67\x31\x0a\x83\x65\x83\x58\x83\x67\x32\x0a\x83\x65\x83\x58\x83\x67\x33\x0a" 34 | with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: 35 | f.write(content) 36 | file_path = f.name 37 | 38 | yield file_path 39 | 40 | # Cleanup 41 | if os.path.exists(file_path): 42 | os.unlink(file_path) 43 | 44 | 45 | class MockStream: 46 | """Mock stream for testing.""" 47 | 48 | def __init__(self): 49 | self.data = [] 50 | 51 | async def write(self, data: str) -> None: 52 | """Mock write method.""" 53 | self.data.append(data) 54 | 55 | async def drain(self) -> None: 56 | """Mock drain method.""" 57 | pass 58 | 59 | 60 | @pytest_asyncio.fixture 61 | async def mock_server() -> AsyncGenerator[tuple[Server, MockStream], None]: 62 | """Create a mock server for testing.""" 63 | mock_write_stream = MockStream() 64 | yield app, mock_write_stream 65 | -------------------------------------------------------------------------------- /tests/test_append_text_file.py: -------------------------------------------------------------------------------- 1 | """Test cases for append_text_file_contents handler.""" 2 | 3 | import os 4 | from typing import Any, Dict, Generator 5 | 6 | import pytest 7 | 8 | from mcp_text_editor.server import AppendTextFileContentsHandler 9 | from mcp_text_editor.text_editor import TextEditor 10 | 11 | # Initialize handler for tests 12 | append_handler = AppendTextFileContentsHandler() 13 | 14 | 15 | @pytest.fixture 16 | def test_dir(tmp_path: str) -> str: 17 | """Create a temporary directory for test files.""" 18 | return str(tmp_path) 19 | 20 | 21 | @pytest.fixture 22 | def cleanup_files() -> Generator[None, None, None]: 23 | """Clean up any test files after each test.""" 24 | yield 25 | # Add cleanup code if needed 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_append_text_file_success(test_dir: str, cleanup_files: None) -> None: 30 | """Test successful appending to a file.""" 31 | test_file = os.path.join(test_dir, "append_test.txt") 32 | initial_content = "Initial content\n" 33 | append_content = "Appended content\n" 34 | 35 | # Create initial file 36 | with open(test_file, "w", encoding="utf-8") as f: 37 | f.write(initial_content) 38 | 39 | # Get file hash for append operation 40 | editor = TextEditor() 41 | _, _, _, file_hash, _, _ = await editor.read_file_contents(test_file) 42 | 43 | # Append content using handler 44 | arguments: Dict[str, Any] = { 45 | "file_path": test_file, 46 | "contents": append_content, 47 | "file_hash": file_hash, 48 | } 49 | response = await append_handler.run_tool(arguments) 50 | 51 | # Check if content was appended correctly 52 | with open(test_file, "r", encoding="utf-8") as f: 53 | content = f.read() 54 | assert content == initial_content + append_content 55 | 56 | # Parse response to check success 57 | assert len(response) == 1 58 | result = response[0].text 59 | assert '"result": "ok"' in result 60 | 61 | 62 | @pytest.mark.asyncio 63 | async def test_append_text_file_not_exists(test_dir: str, cleanup_files: None) -> None: 64 | """Test attempting to append to a non-existent file.""" 65 | test_file = os.path.join(test_dir, "nonexistent.txt") 66 | 67 | # Try to append to non-existent file 68 | arguments: Dict[str, Any] = { 69 | "file_path": test_file, 70 | "contents": "Some content\n", 71 | "file_hash": "dummy_hash", 72 | } 73 | 74 | # Should raise error because file doesn't exist 75 | with pytest.raises(RuntimeError) as exc_info: 76 | await append_handler.run_tool(arguments) 77 | assert "File does not exist" in str(exc_info.value) 78 | 79 | 80 | @pytest.mark.asyncio 81 | async def test_append_text_file_hash_mismatch( 82 | test_dir: str, cleanup_files: None 83 | ) -> None: 84 | """Test appending with incorrect file hash.""" 85 | test_file = os.path.join(test_dir, "hash_test.txt") 86 | initial_content = "Initial content\n" 87 | 88 | # Create initial file 89 | with open(test_file, "w", encoding="utf-8") as f: 90 | f.write(initial_content) 91 | 92 | # Try to append with incorrect hash 93 | arguments: Dict[str, Any] = { 94 | "file_path": test_file, 95 | "contents": "New content\n", 96 | "file_hash": "incorrect_hash", 97 | } 98 | 99 | # Should raise error because hash doesn't match 100 | with pytest.raises(RuntimeError) as exc_info: 101 | await append_handler.run_tool(arguments) 102 | assert "hash mismatch" in str(exc_info.value).lower() 103 | 104 | 105 | @pytest.mark.asyncio 106 | async def test_append_text_file_relative_path( 107 | test_dir: str, cleanup_files: None 108 | ) -> None: 109 | """Test attempting to append using a relative path.""" 110 | arguments: Dict[str, Any] = { 111 | "file_path": "relative_path.txt", 112 | "contents": "Some content\n", 113 | "file_hash": "dummy_hash", 114 | } 115 | 116 | # Should raise error because path is not absolute 117 | with pytest.raises(RuntimeError) as exc_info: 118 | await append_handler.run_tool(arguments) 119 | assert "File path must be absolute" in str(exc_info.value) 120 | 121 | 122 | @pytest.mark.asyncio 123 | async def test_append_text_file_missing_args() -> None: 124 | """Test appending with missing arguments.""" 125 | # Test missing path 126 | with pytest.raises(RuntimeError) as exc_info: 127 | await append_handler.run_tool({"contents": "content\n", "file_hash": "hash"}) 128 | assert "Missing required argument: file_path" in str(exc_info.value) 129 | 130 | # Test missing contents 131 | with pytest.raises(RuntimeError) as exc_info: 132 | await append_handler.run_tool( 133 | {"file_path": "/absolute/path.txt", "file_hash": "hash"} 134 | ) 135 | assert "Missing required argument: contents" in str(exc_info.value) 136 | 137 | # Test missing file_hash 138 | with pytest.raises(RuntimeError) as exc_info: 139 | await append_handler.run_tool( 140 | {"file_path": "/absolute/path.txt", "contents": "content\n"} 141 | ) 142 | assert "Missing required argument: file_hash" in str(exc_info.value) 143 | 144 | 145 | @pytest.mark.asyncio 146 | async def test_append_text_file_custom_encoding( 147 | test_dir: str, cleanup_files: None 148 | ) -> None: 149 | """Test appending with custom encoding.""" 150 | test_file = os.path.join(test_dir, "encode_test.txt") 151 | initial_content = "こんにちは\n" 152 | append_content = "さようなら\n" 153 | 154 | # Create initial file 155 | with open(test_file, "w", encoding="utf-8") as f: 156 | f.write(initial_content) 157 | 158 | # Get file hash for append operation 159 | editor = TextEditor() 160 | _, _, _, file_hash, _, _ = await editor.read_file_contents( 161 | test_file, encoding="utf-8" 162 | ) 163 | 164 | # Append content using handler with specified encoding 165 | arguments: Dict[str, Any] = { 166 | "file_path": test_file, 167 | "contents": append_content, 168 | "file_hash": file_hash, 169 | "encoding": "utf-8", 170 | } 171 | response = await append_handler.run_tool(arguments) 172 | 173 | # Check if content was appended correctly 174 | with open(test_file, "r", encoding="utf-8") as f: 175 | content = f.read() 176 | assert content == initial_content + append_content 177 | 178 | # Parse response to check success 179 | assert len(response) == 1 180 | result = response[0].text 181 | assert '"result": "ok"' in result 182 | -------------------------------------------------------------------------------- /tests/test_create_error_response.py: -------------------------------------------------------------------------------- 1 | """Tests for error response creation and hint/suggestion functionality.""" 2 | 3 | import pytest 4 | 5 | from mcp_text_editor.text_editor import TextEditor 6 | 7 | 8 | @pytest.fixture 9 | def editor(): 10 | """Create TextEditor instance.""" 11 | return TextEditor() 12 | 13 | 14 | def test_create_error_response_basic(editor): 15 | """Test basic error response without hint/suggestion.""" 16 | response = editor.create_error_response("Test error") 17 | assert response["result"] == "error" 18 | assert response["reason"] == "Test error" 19 | assert response["file_hash"] is None 20 | assert "hint" not in response 21 | assert "suggestion" not in response 22 | 23 | 24 | def test_create_error_response_with_hint_suggestion(editor): 25 | """Test error response with hint and suggestion.""" 26 | response = editor.create_error_response( 27 | "Test error", suggestion="append", hint="Please use append_text_file_contents" 28 | ) 29 | assert response["result"] == "error" 30 | assert response["reason"] == "Test error" 31 | assert response["suggestion"] == "append" 32 | assert response["hint"] == "Please use append_text_file_contents" 33 | 34 | 35 | def test_create_error_response_with_file_path(editor): 36 | """Test error response with file path.""" 37 | response = editor.create_error_response( 38 | "Test error", 39 | file_path="/test/file.txt", 40 | suggestion="patch", 41 | hint="Please try again", 42 | ) 43 | assert "/test/file.txt" in response 44 | assert response["/test/file.txt"]["result"] == "error" 45 | assert response["/test/file.txt"]["reason"] == "Test error" 46 | assert response["/test/file.txt"]["suggestion"] == "patch" 47 | assert response["/test/file.txt"]["hint"] == "Please try again" 48 | 49 | 50 | def test_create_error_response_with_hash(editor): 51 | """Test error response with content hash.""" 52 | test_hash = "test_hash_value" 53 | response = editor.create_error_response("Test error", content_hash=test_hash) 54 | assert response["result"] == "error" 55 | assert response["reason"] == "Test error" 56 | assert response["file_hash"] == test_hash 57 | -------------------------------------------------------------------------------- /tests/test_create_text_file.py: -------------------------------------------------------------------------------- 1 | """Test cases for create_text_file handler.""" 2 | 3 | import os 4 | from typing import Any, Dict, Generator 5 | 6 | import pytest 7 | 8 | from mcp_text_editor.server import CreateTextFileHandler 9 | 10 | # Initialize handlers for tests 11 | create_file_handler = CreateTextFileHandler() 12 | 13 | 14 | @pytest.fixture 15 | def test_dir(tmp_path: str) -> str: 16 | """Create a temporary directory for test files.""" 17 | return str(tmp_path) 18 | 19 | 20 | @pytest.fixture 21 | def cleanup_files() -> Generator[None, None, None]: 22 | """Clean up any test files after each test.""" 23 | yield 24 | # Add cleanup code if needed 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_create_text_file_success(test_dir: str, cleanup_files: None) -> None: 29 | """Test successful creation of a new text file.""" 30 | test_file = os.path.join(test_dir, "new_file.txt") 31 | content = "Hello, World!\n" 32 | 33 | # Create file using handler 34 | arguments: Dict[str, Any] = { 35 | "file_path": test_file, 36 | "contents": content, 37 | } 38 | response = await create_file_handler.run_tool(arguments) 39 | 40 | # Check if file was created with correct content 41 | assert os.path.exists(test_file) 42 | with open(test_file, "r", encoding="utf-8") as f: 43 | assert f.read() == content 44 | 45 | # Parse response to check success 46 | assert len(response) == 1 47 | result = response[0].text 48 | assert '"result": "ok"' in result 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_create_text_file_exists(test_dir: str, cleanup_files: None) -> None: 53 | """Test attempting to create a file that already exists.""" 54 | test_file = os.path.join(test_dir, "existing_file.txt") 55 | 56 | # Create file first 57 | with open(test_file, "w", encoding="utf-8") as f: 58 | f.write("Existing content\n") 59 | 60 | # Try to create file using handler 61 | arguments: Dict[str, Any] = { 62 | "file_path": test_file, 63 | "contents": "New content\n", 64 | } 65 | 66 | # Should raise error because file exists 67 | with pytest.raises(RuntimeError): 68 | await create_file_handler.run_tool(arguments) 69 | 70 | 71 | @pytest.mark.asyncio 72 | async def test_create_text_file_relative_path( 73 | test_dir: str, cleanup_files: None 74 | ) -> None: 75 | """Test attempting to create a file with a relative path.""" 76 | # Try to create file using relative path 77 | arguments: Dict[str, Any] = { 78 | "file_path": "relative_path.txt", 79 | "contents": "Some content\n", 80 | } 81 | 82 | # Should raise error because path is not absolute 83 | with pytest.raises(RuntimeError) as exc_info: 84 | await create_file_handler.run_tool(arguments) 85 | assert "File path must be absolute" in str(exc_info.value) 86 | 87 | 88 | @pytest.mark.asyncio 89 | async def test_create_text_file_missing_args() -> None: 90 | """Test creating a file with missing arguments.""" 91 | # Test missing path 92 | with pytest.raises(RuntimeError) as exc_info: 93 | await create_file_handler.run_tool({"contents": "content\n"}) 94 | assert "Missing required argument: file_path" in str(exc_info.value) 95 | 96 | # Test missing contents 97 | with pytest.raises(RuntimeError) as exc_info: 98 | await create_file_handler.run_tool({"file_path": "/absolute/path.txt"}) 99 | assert "Missing required argument: contents" in str(exc_info.value) 100 | 101 | 102 | @pytest.mark.asyncio 103 | async def test_create_text_file_custom_encoding( 104 | test_dir: str, cleanup_files: None 105 | ) -> None: 106 | """Test creating a file with custom encoding.""" 107 | test_file = os.path.join(test_dir, "encoded_file.txt") 108 | content = "こんにちは\n" # Japanese text 109 | 110 | # Create file using handler with specified encoding 111 | arguments: Dict[str, Any] = { 112 | "file_path": test_file, 113 | "contents": content, 114 | "encoding": "utf-8", 115 | } 116 | response = await create_file_handler.run_tool(arguments) 117 | 118 | # Check if file was created with correct content 119 | assert os.path.exists(test_file) 120 | with open(test_file, "r", encoding="utf-8") as f: 121 | assert f.read() == content 122 | 123 | # Parse response to check success 124 | assert len(response) == 1 125 | result = response[0].text 126 | assert '"result": "ok"' in result 127 | -------------------------------------------------------------------------------- /tests/test_delete_file_contents.py: -------------------------------------------------------------------------------- 1 | """Tests for delete_text_file_contents functionality.""" 2 | 3 | import json 4 | 5 | import pytest 6 | 7 | from mcp_text_editor.models import DeleteTextFileContentsRequest, FileRange 8 | from mcp_text_editor.service import TextEditorService 9 | 10 | 11 | @pytest.fixture 12 | def service(): 13 | """Create TextEditorService instance.""" 14 | return TextEditorService() 15 | 16 | 17 | def test_delete_text_file_contents_basic(service, tmp_path): 18 | """Test basic delete operation.""" 19 | # Create test file 20 | test_file = tmp_path / "delete_test.txt" 21 | test_content = "line1\nline2\nline3\n" 22 | test_file.write_text(test_content) 23 | file_path = str(test_file) 24 | 25 | # Calculate initial hash 26 | initial_hash = service.calculate_hash(test_content) 27 | 28 | # Create delete request 29 | request = DeleteTextFileContentsRequest( 30 | file_path=file_path, 31 | file_hash=initial_hash, 32 | ranges=[ 33 | FileRange(start=2, end=2, range_hash=service.calculate_hash("line2\n")) 34 | ], 35 | encoding="utf-8", 36 | ) 37 | 38 | # Apply delete 39 | result = service.delete_text_file_contents(request) 40 | assert file_path in result 41 | delete_result = result[file_path] 42 | assert delete_result.result == "ok" 43 | 44 | # Verify changes 45 | new_content = test_file.read_text() 46 | assert new_content == "line1\nline3\n" 47 | 48 | 49 | def test_delete_text_file_contents_hash_mismatch(service, tmp_path): 50 | """Test deleting with hash mismatch.""" 51 | # Create test file 52 | test_file = tmp_path / "hash_mismatch_test.txt" 53 | test_content = "line1\nline2\nline3\n" 54 | test_file.write_text(test_content) 55 | file_path = str(test_file) 56 | 57 | # Create delete request with incorrect hash 58 | request = DeleteTextFileContentsRequest( 59 | file_path=file_path, 60 | file_hash="incorrect_hash", 61 | ranges=[ 62 | FileRange(start=2, end=2, range_hash=service.calculate_hash("line2\n")) 63 | ], 64 | encoding="utf-8", 65 | ) 66 | 67 | # Attempt delete 68 | result = service.delete_text_file_contents(request) 69 | assert file_path in result 70 | delete_result = result[file_path] 71 | assert delete_result.result == "error" 72 | assert "hash mismatch" in delete_result.reason.lower() 73 | 74 | 75 | def test_delete_text_file_contents_invalid_ranges(service, tmp_path): 76 | """Test deleting with invalid ranges.""" 77 | # Create test file 78 | test_file = tmp_path / "invalid_ranges_test.txt" 79 | test_content = "line1\nline2\nline3\n" 80 | test_file.write_text(test_content) 81 | file_path = str(test_file) 82 | 83 | # Calculate initial hash 84 | initial_hash = service.calculate_hash(test_content) 85 | 86 | # Create delete request with invalid ranges 87 | request = DeleteTextFileContentsRequest( 88 | file_path=file_path, 89 | file_hash=initial_hash, 90 | ranges=[FileRange(start=1, end=10, range_hash="hash1")], # Beyond file length 91 | encoding="utf-8", 92 | ) 93 | 94 | # Attempt delete 95 | result = service.delete_text_file_contents(request) 96 | assert file_path in result 97 | delete_result = result[file_path] 98 | assert delete_result.result == "error" 99 | assert "invalid ranges" in delete_result.reason.lower() 100 | 101 | 102 | def test_delete_text_file_contents_range_hash_mismatch(service, tmp_path): 103 | """Test deleting with range hash mismatch.""" 104 | # Create test file 105 | test_file = tmp_path / "range_hash_test.txt" 106 | test_content = "line1\nline2\nline3\n" 107 | test_file.write_text(test_content) 108 | file_path = str(test_file) 109 | 110 | # Calculate initial hash 111 | initial_hash = service.calculate_hash(test_content) 112 | 113 | # Create delete request with incorrect range hash 114 | request = DeleteTextFileContentsRequest( 115 | file_path=file_path, 116 | file_hash=initial_hash, 117 | ranges=[FileRange(start=2, end=2, range_hash="incorrect_hash")], 118 | encoding="utf-8", 119 | ) 120 | 121 | # Attempt delete 122 | result = service.delete_text_file_contents(request) 123 | assert file_path in result 124 | delete_result = result[file_path] 125 | assert delete_result.result == "error" 126 | assert "hash mismatch for range" in delete_result.reason.lower() 127 | 128 | 129 | def test_delete_text_file_contents_relative_path(service, tmp_path): 130 | """Test deleting with a relative file path.""" 131 | # Create delete request with relative path 132 | request = DeleteTextFileContentsRequest( 133 | file_path="relative/path.txt", 134 | file_hash="some_hash", 135 | ranges=[FileRange(start=1, end=1, range_hash="hash1")], 136 | encoding="utf-8", 137 | ) 138 | 139 | # Attempt delete 140 | result = service.delete_text_file_contents(request) 141 | assert "relative/path.txt" in result 142 | delete_result = result["relative/path.txt"] 143 | assert delete_result.result == "error" 144 | assert "no such file or directory" in delete_result.reason.lower() 145 | 146 | 147 | def test_delete_text_file_contents_empty_ranges(service, tmp_path): 148 | """Test deleting with empty ranges list.""" 149 | test_file = tmp_path / "empty_ranges.txt" 150 | test_content = "line1\nline2\nline3\n" 151 | test_file.write_text(test_content) 152 | file_path = str(test_file) 153 | content_hash = service.calculate_hash(test_content) 154 | 155 | # Test empty ranges 156 | request = DeleteTextFileContentsRequest( 157 | file_path=file_path, 158 | file_hash=content_hash, 159 | ranges=[], # Empty ranges list 160 | encoding="utf-8", 161 | ) 162 | 163 | result = service.delete_text_file_contents(request) 164 | assert file_path in result 165 | delete_result = result[file_path] 166 | assert delete_result.result == "error" 167 | assert "missing required argument: ranges" in delete_result.reason.lower() 168 | 169 | 170 | def test_delete_text_file_contents_nonexistent_file(service, tmp_path): 171 | """Test deleting content from a nonexistent file.""" 172 | file_path = str(tmp_path / "nonexistent.txt") 173 | 174 | # Create delete request for nonexistent file 175 | request = DeleteTextFileContentsRequest( 176 | file_path=file_path, 177 | file_hash="some_hash", 178 | ranges=[FileRange(start=1, end=1, range_hash="hash1")], 179 | encoding="utf-8", 180 | ) 181 | 182 | # Attempt delete 183 | result = service.delete_text_file_contents(request) 184 | assert file_path in result 185 | delete_result = result[file_path] 186 | assert delete_result.result == "error" 187 | assert "no such file or directory" in delete_result.reason.lower() 188 | 189 | 190 | def test_delete_text_file_contents_multiple_ranges(service, tmp_path): 191 | """Test deleting multiple ranges simultaneously.""" 192 | # Create test file 193 | test_file = tmp_path / "multiple_ranges_test.txt" 194 | test_content = "line1\nline2\nline3\nline4\nline5\n" 195 | test_file.write_text(test_content) 196 | file_path = str(test_file) 197 | 198 | # Calculate initial hash 199 | initial_hash = service.calculate_hash(test_content) 200 | 201 | # Create delete request with multiple ranges 202 | request = DeleteTextFileContentsRequest( 203 | file_path=file_path, 204 | file_hash=initial_hash, 205 | ranges=[ 206 | FileRange(start=2, end=2, range_hash=service.calculate_hash("line2\n")), 207 | FileRange(start=4, end=4, range_hash=service.calculate_hash("line4\n")), 208 | ], 209 | encoding="utf-8", 210 | ) 211 | 212 | # Apply delete 213 | result = service.delete_text_file_contents(request) 214 | assert file_path in result 215 | delete_result = result[file_path] 216 | assert delete_result.result == "ok" 217 | 218 | # Verify changes 219 | new_content = test_file.read_text() 220 | assert new_content == "line1\nline3\nline5\n" 221 | 222 | 223 | @pytest.mark.asyncio 224 | async def test_delete_text_file_contents_handler_validation(): 225 | """Test validation in DeleteTextFileContentsHandler.""" 226 | from mcp_text_editor.handlers.delete_text_file_contents import ( 227 | DeleteTextFileContentsHandler, 228 | ) 229 | from mcp_text_editor.text_editor import TextEditor 230 | 231 | editor = TextEditor() 232 | handler = DeleteTextFileContentsHandler(editor) 233 | 234 | # Test missing file_hash 235 | with pytest.raises(RuntimeError) as exc_info: 236 | arguments = { 237 | "file_path": "/absolute/path.txt", 238 | "ranges": [{"start": 1, "end": 1, "range_hash": "hash1"}], 239 | "encoding": "utf-8", 240 | } 241 | await handler.run_tool(arguments) 242 | assert "Missing required argument: file_hash" in str(exc_info.value) 243 | 244 | # Test missing ranges 245 | with pytest.raises(RuntimeError) as exc_info: 246 | arguments = { 247 | "file_path": "/absolute/path.txt", 248 | "file_hash": "some_hash", 249 | "encoding": "utf-8", 250 | } 251 | await handler.run_tool(arguments) 252 | assert "Missing required argument: ranges" in str(exc_info.value) 253 | 254 | # Test missing file_path 255 | with pytest.raises(RuntimeError) as exc_info: 256 | arguments = { 257 | "file_hash": "some_hash", 258 | "ranges": [{"start": 1, "end": 1, "range_hash": "hash1"}], 259 | "encoding": "utf-8", 260 | } 261 | await handler.run_tool(arguments) 262 | assert "Missing required argument: file_path" in str(exc_info.value) 263 | 264 | # Test relative file path 265 | with pytest.raises(RuntimeError) as exc_info: 266 | arguments = { 267 | "file_path": "relative/path.txt", 268 | "file_hash": "some_hash", 269 | "ranges": [{"start": 1, "end": 1, "range_hash": "hash1"}], 270 | "encoding": "utf-8", 271 | } 272 | await handler.run_tool(arguments) 273 | assert "File path must be absolute" in str(exc_info.value) 274 | 275 | 276 | @pytest.mark.asyncio 277 | async def test_delete_text_file_contents_handler_runtime_error(tmp_path): 278 | """Test runtime error handling in DeleteTextFileContentsHandler.""" 279 | from mcp_text_editor.handlers.delete_text_file_contents import ( 280 | DeleteTextFileContentsHandler, 281 | ) 282 | from mcp_text_editor.service import TextEditorService 283 | from mcp_text_editor.text_editor import TextEditor 284 | 285 | class MockService(TextEditorService): 286 | def delete_text_file_contents(self, request): 287 | raise RuntimeError("Mock error during delete") 288 | 289 | editor = TextEditor() 290 | editor.service = MockService() 291 | handler = DeleteTextFileContentsHandler(editor) 292 | 293 | test_file = tmp_path / "error_test.txt" 294 | test_file.write_text("test content") 295 | 296 | with pytest.raises(RuntimeError) as exc_info: 297 | arguments = { 298 | "file_path": str(test_file), 299 | "file_hash": "some_hash", 300 | "ranges": [{"start": 1, "end": 1, "range_hash": "hash1"}], 301 | "encoding": "utf-8", 302 | } 303 | await handler.run_tool(arguments) 304 | 305 | assert "Error processing request: Mock error during delete" in str(exc_info.value) 306 | 307 | 308 | @pytest.mark.asyncio 309 | async def test_delete_text_file_contents_handler_success(tmp_path): 310 | """Test successful execution of DeleteTextFileContentsHandler including JSON serialization.""" 311 | from mcp_text_editor.handlers.delete_text_file_contents import ( 312 | DeleteTextFileContentsHandler, 313 | ) 314 | from mcp_text_editor.models import EditResult 315 | from mcp_text_editor.service import TextEditorService 316 | from mcp_text_editor.text_editor import TextEditor 317 | 318 | class MockService(TextEditorService): 319 | def delete_text_file_contents(self, request): 320 | return { 321 | request.file_path: EditResult(result="ok", hash="new_hash", reason=None) 322 | } 323 | 324 | editor = TextEditor() 325 | editor.service = MockService() 326 | handler = DeleteTextFileContentsHandler(editor) 327 | 328 | test_file = tmp_path / "test.txt" 329 | test_file.write_text("test content") 330 | 331 | arguments = { 332 | "file_path": str(test_file), 333 | "file_hash": "some_hash", 334 | "ranges": [{"start": 1, "end": 1, "range_hash": "hash1"}], 335 | } 336 | 337 | result = await handler.run_tool(arguments) 338 | assert len(result) == 1 339 | assert result[0].type == "text" 340 | 341 | # Check if response is JSON serializable 342 | response = json.loads(result[0].text) 343 | assert str(test_file) in response 344 | assert response[str(test_file)]["result"] == "ok" 345 | assert response[str(test_file)]["hash"] == "new_hash" 346 | -------------------------------------------------------------------------------- /tests/test_delete_text_file.py: -------------------------------------------------------------------------------- 1 | """Tests for delete text file functionality.""" 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from mcp_text_editor.models import DeleteTextFileContentsRequest, FileRange 8 | from mcp_text_editor.text_editor import TextEditor 9 | 10 | 11 | @pytest.fixture 12 | def editor() -> TextEditor: 13 | """Create TextEditor instance.""" 14 | return TextEditor() 15 | 16 | 17 | @pytest.fixture 18 | def test_file(tmp_path) -> Path: 19 | """Create a test file with sample content.""" 20 | test_file = tmp_path / "test.txt" 21 | test_file.write_text("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n") 22 | return test_file 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_delete_single_line(editor, test_file): 27 | """Test deleting a single line from file.""" 28 | # Get initial content and file hash 29 | content, _, _, file_hash, _, _ = await editor.read_file_contents(str(test_file)) 30 | original_lines = content.splitlines(keepends=True) 31 | 32 | # Get hash for line 2 33 | line2_hash = editor.calculate_hash(original_lines[1]) 34 | 35 | # Create delete request 36 | request = DeleteTextFileContentsRequest( 37 | file_path=str(test_file), 38 | file_hash=file_hash, 39 | ranges=[ 40 | FileRange( 41 | start=2, 42 | end=2, 43 | range_hash=line2_hash, 44 | ) 45 | ], 46 | ) 47 | 48 | # Delete line 2 49 | result = await editor.delete_text_file_contents(request) 50 | 51 | assert result[str(test_file)]["result"] == "ok" 52 | assert test_file.read_text() == "Line 1\nLine 3\nLine 4\nLine 5\n" 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_delete_multiple_lines(editor, test_file): 57 | """Test deleting multiple consecutive lines from file.""" 58 | # Get initial content and file hash 59 | content, _, _, file_hash, _, _ = await editor.read_file_contents(str(test_file)) 60 | original_lines = content.splitlines(keepends=True) 61 | 62 | # Get hash for lines 2-4 63 | lines_hash = editor.calculate_hash("".join(original_lines[1:4])) 64 | 65 | # Create delete request 66 | request = DeleteTextFileContentsRequest( 67 | file_path=str(test_file), 68 | file_hash=file_hash, 69 | ranges=[ 70 | FileRange( 71 | start=2, 72 | end=4, 73 | range_hash=lines_hash, 74 | ) 75 | ], 76 | ) 77 | 78 | # Delete lines 2-4 79 | result = await editor.delete_text_file_contents(request) 80 | 81 | assert result[str(test_file)]["result"] == "ok" 82 | assert test_file.read_text() == "Line 1\nLine 5\n" 83 | 84 | 85 | @pytest.mark.asyncio 86 | async def test_delete_with_invalid_file_hash(editor, test_file): 87 | """Test deleting with an invalid file hash.""" 88 | content, _, _, file_hash, _, _ = await editor.read_file_contents(str(test_file)) 89 | original_lines = content.splitlines(keepends=True) 90 | line2_hash = editor.calculate_hash(original_lines[1]) 91 | 92 | request = DeleteTextFileContentsRequest( 93 | file_path=str(test_file), 94 | file_hash="invalid_hash", 95 | ranges=[ 96 | FileRange( 97 | start=2, 98 | end=2, 99 | range_hash=line2_hash, 100 | ) 101 | ], 102 | ) 103 | 104 | result = await editor.delete_text_file_contents(request) 105 | 106 | assert result[str(test_file)]["result"] == "error" 107 | assert "hash mismatch" in result[str(test_file)]["reason"].lower() 108 | assert test_file.read_text() == "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n" 109 | 110 | 111 | @pytest.mark.asyncio 112 | async def test_delete_with_invalid_range_hash(editor, test_file): 113 | """Test deleting with an invalid range hash.""" 114 | content, _, _, file_hash, _, _ = await editor.read_file_contents(str(test_file)) 115 | 116 | request = DeleteTextFileContentsRequest( 117 | file_path=str(test_file), 118 | file_hash=file_hash, 119 | ranges=[ 120 | FileRange( 121 | start=2, 122 | end=2, 123 | range_hash="invalid_hash", 124 | ) 125 | ], 126 | ) 127 | 128 | result = await editor.delete_text_file_contents(request) 129 | 130 | assert result[str(test_file)]["result"] == "error" 131 | assert "hash mismatch" in result[str(test_file)]["reason"].lower() 132 | assert test_file.read_text() == "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n" 133 | 134 | 135 | @pytest.mark.asyncio 136 | async def test_delete_with_invalid_range(editor, test_file): 137 | """Test deleting with invalid line range.""" 138 | content, _, _, file_hash, _, _ = await editor.read_file_contents(str(test_file)) 139 | line2_hash = editor.calculate_hash("Line 2\n") 140 | 141 | request = DeleteTextFileContentsRequest( 142 | file_path=str(test_file), 143 | file_hash=file_hash, 144 | ranges=[ 145 | FileRange( 146 | start=2, 147 | end=1, # Invalid: end before start 148 | range_hash=line2_hash, 149 | ) 150 | ], 151 | ) 152 | 153 | result = await editor.delete_text_file_contents(request) 154 | 155 | assert result[str(test_file)]["result"] == "error" 156 | assert ( 157 | "end line" in result[str(test_file)]["reason"].lower() 158 | and "less than start line" in result[str(test_file)]["reason"].lower() 159 | ) 160 | assert test_file.read_text() == "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n" 161 | 162 | 163 | @pytest.mark.asyncio 164 | async def test_delete_with_out_of_range(editor, test_file): 165 | """Test deleting lines beyond file length.""" 166 | content, _, _, file_hash, _, _ = await editor.read_file_contents(str(test_file)) 167 | 168 | request = DeleteTextFileContentsRequest( 169 | file_path=str(test_file), 170 | file_hash=file_hash, 171 | ranges=[ 172 | FileRange( 173 | start=10, 174 | end=12, 175 | range_hash="any_hash", # Hash doesn't matter as it will fail before hash check 176 | ) 177 | ], 178 | ) 179 | 180 | result = await editor.delete_text_file_contents(request) 181 | 182 | assert result[str(test_file)]["result"] == "error" 183 | assert ( 184 | "start line" in result[str(test_file)]["reason"].lower() 185 | and "exceeds file length" in result[str(test_file)]["reason"].lower() 186 | ) 187 | assert test_file.read_text() == "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n" 188 | 189 | 190 | @pytest.mark.asyncio 191 | async def test_delete_with_overlapping_ranges(editor, test_file): 192 | """Test deleting with overlapping ranges.""" 193 | content, _, _, file_hash, _, _ = await editor.read_file_contents(str(test_file)) 194 | 195 | # Prepare hashes for the ranges 196 | lines = content.splitlines(keepends=True) 197 | range1_hash = editor.calculate_hash("".join(lines[1:3])) # Lines 2-3 198 | range2_hash = editor.calculate_hash("".join(lines[2:4])) # Lines 3-4 199 | 200 | request = DeleteTextFileContentsRequest( 201 | file_path=str(test_file), 202 | file_hash=file_hash, 203 | ranges=[ 204 | FileRange( 205 | start=2, 206 | end=3, 207 | range_hash=range1_hash, 208 | ), 209 | FileRange( 210 | start=3, 211 | end=4, 212 | range_hash=range2_hash, 213 | ), 214 | ], 215 | ) 216 | 217 | result = await editor.delete_text_file_contents(request) 218 | 219 | assert result[str(test_file)]["result"] == "error" 220 | assert "overlapping ranges" in result[str(test_file)]["reason"].lower() 221 | assert test_file.read_text() == "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n" 222 | 223 | 224 | @pytest.mark.asyncio 225 | async def test_delete_multiple_ranges(editor, test_file): 226 | """Test deleting multiple non-consecutive ranges.""" 227 | content, _, _, file_hash, _, _ = await editor.read_file_contents(str(test_file)) 228 | 229 | # Prepare hashes for the ranges 230 | lines = content.splitlines(keepends=True) 231 | range1_hash = editor.calculate_hash(lines[1]) # Line 2 232 | range2_hash = editor.calculate_hash(lines[3]) # Line 4 233 | 234 | request = DeleteTextFileContentsRequest( 235 | file_path=str(test_file), 236 | file_hash=file_hash, 237 | ranges=[ 238 | FileRange( 239 | start=2, 240 | end=2, 241 | range_hash=range1_hash, 242 | ), 243 | FileRange( 244 | start=4, 245 | end=4, 246 | range_hash=range2_hash, 247 | ), 248 | ], 249 | ) 250 | 251 | result = await editor.delete_text_file_contents(request) 252 | 253 | assert result[str(test_file)]["result"] == "ok" 254 | assert test_file.read_text() == "Line 1\nLine 3\nLine 5\n" 255 | -------------------------------------------------------------------------------- /tests/test_error_hints.py: -------------------------------------------------------------------------------- 1 | """Tests for error hints and suggestions functionality.""" 2 | 3 | import pytest 4 | 5 | from mcp_text_editor.text_editor import TextEditor 6 | 7 | 8 | @pytest.fixture 9 | def editor(): 10 | """Create TextEditor instance.""" 11 | return TextEditor() 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_file_not_found_hint(editor, tmp_path): 16 | """Test hints when file is not found.""" 17 | non_existent = tmp_path / "non_existent.txt" 18 | 19 | result = await editor.edit_file_contents( 20 | str(non_existent), 21 | "non_empty_hash", 22 | [{"start": 1, "contents": "test", "range_hash": ""}], 23 | ) 24 | 25 | assert result["result"] == "error" 26 | assert "File not found" in result["reason"] 27 | assert result["suggestion"] == "append" 28 | assert "append_text_file_contents" in result["hint"] 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_hash_mismatch_hint(editor, tmp_path): 33 | """Test hints when file hash doesn't match.""" 34 | test_file = tmp_path / "test.txt" 35 | test_file.write_text("original content\\n") 36 | 37 | result = await editor.edit_file_contents( 38 | str(test_file), 39 | "wrong_hash", 40 | [{"start": 1, "contents": "new content", "range_hash": ""}], 41 | ) 42 | 43 | assert result["result"] == "error" 44 | assert "hash mismatch" in result["reason"].lower() 45 | assert result["suggestion"] == "patch" 46 | assert "get_text_file_contents tool" in result["hint"] 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_overlapping_patches_hint(editor, tmp_path): 51 | """Test hints when patches overlap.""" 52 | test_file = tmp_path / "test.txt" 53 | test_file.write_text("line1\\nline2\\nline3\\n") 54 | 55 | content, _, _, file_hash, _, _ = await editor.read_file_contents(str(test_file)) 56 | 57 | result = await editor.edit_file_contents( 58 | str(test_file), 59 | file_hash, 60 | [ 61 | { 62 | "start": 1, 63 | "end": 2, 64 | "contents": "new1\\n", 65 | "range_hash": editor.calculate_hash("line1\\nline2\\n"), 66 | }, 67 | { 68 | "start": 2, 69 | "end": 3, 70 | "contents": "new2\\n", 71 | "range_hash": editor.calculate_hash("line2\\nline3\\n"), 72 | }, 73 | ], 74 | ) 75 | 76 | assert result["result"] == "error" 77 | assert "overlap" in result["reason"].lower() 78 | assert result["suggestion"] == "patch" 79 | assert "not overlap" in result["hint"].lower() 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_io_error_hint(editor, tmp_path, monkeypatch): 84 | """Test hints when IO error occurs.""" 85 | test_file = tmp_path / "test.txt" 86 | test_file.write_text("original content\\n") 87 | 88 | def mock_open(*args, **kwargs): 89 | raise IOError("Test IO Error") 90 | 91 | monkeypatch.setattr("builtins.open", mock_open) 92 | 93 | result = await editor.edit_file_contents( 94 | str(test_file), "", [{"start": 1, "contents": "new content\\n"}] 95 | ) 96 | 97 | assert result["result"] == "error" 98 | assert "Error editing file" in result["reason"] 99 | assert result["suggestion"] == "patch" 100 | assert "permissions" in result["hint"].lower() 101 | 102 | 103 | @pytest.mark.asyncio 104 | async def test_empty_content_delete_hint(editor, tmp_path): 105 | """Test hints when trying to delete content using empty patch.""" 106 | test_file = tmp_path / "test.txt" 107 | test_file.write_text("original\\n") 108 | 109 | content, _, _, file_hash, _, _ = await editor.read_file_contents(str(test_file)) 110 | 111 | result = await editor.edit_file_contents( 112 | str(test_file), 113 | file_hash, 114 | [ 115 | { 116 | "start": 1, 117 | "end": 1, 118 | "contents": "", 119 | "range_hash": editor.calculate_hash("original\\n"), 120 | } 121 | ], 122 | ) 123 | 124 | assert result["result"] == "ok" # Note: It's "ok" but suggests using delete 125 | assert result["suggestion"] == "delete" 126 | assert "delete_text_file_contents" in result["hint"] 127 | 128 | 129 | @pytest.mark.asyncio 130 | async def test_append_suggestion_for_new_file(editor, tmp_path): 131 | """Test suggestion to use append for new files.""" 132 | test_file = tmp_path / "new.txt" 133 | 134 | result = await editor.edit_file_contents( 135 | str(test_file), 136 | "", 137 | [{"start": 1, "contents": "new content\\n", "range_hash": ""}], 138 | ) 139 | 140 | assert result["result"] == "ok" 141 | assert result["suggestion"] == "append" 142 | assert "append_text_file_contents" in result["hint"] 143 | -------------------------------------------------------------------------------- /tests/test_insert_text_file.py: -------------------------------------------------------------------------------- 1 | """Test insert_text_file functionality using TextEditor.""" 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from mcp_text_editor.text_editor import TextEditor 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_insert_after_line(tmp_path: Path) -> None: 12 | """Test inserting text after a specific line.""" 13 | # Create a test file 14 | test_file = tmp_path / "test.txt" 15 | test_file.write_text("line1\nline2\nline3\n") 16 | 17 | # Initialize TextEditor 18 | editor = TextEditor() 19 | 20 | # Read file to get hash 21 | result = await editor.read_multiple_ranges( 22 | [{"file_path": str(test_file), "ranges": [{"start": 1}]}] 23 | ) 24 | file_hash = result[str(test_file)]["file_hash"] 25 | 26 | # Insert text after line 2 27 | result = await editor.insert_text_file_contents( 28 | file_path=str(test_file), file_hash=file_hash, after=2, contents="new_line\n" 29 | ) 30 | 31 | assert result["result"] == "ok" 32 | assert result["hash"] is not None 33 | 34 | # Verify the content 35 | content = test_file.read_text() 36 | assert content == "line1\nline2\nnew_line\nline3\n" 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_insert_before_line(tmp_path: Path) -> None: 41 | """Test inserting text before a specific line.""" 42 | # Create a test file 43 | test_file = tmp_path / "test.txt" 44 | test_file.write_text("line1\nline2\nline3\n") 45 | 46 | # Initialize TextEditor 47 | editor = TextEditor() 48 | 49 | # Read file to get hash 50 | result = await editor.read_multiple_ranges( 51 | [{"file_path": str(test_file), "ranges": [{"start": 1}]}] 52 | ) 53 | file_hash = result[str(test_file)]["file_hash"] 54 | 55 | # Insert text before line 2 56 | result = await editor.insert_text_file_contents( 57 | file_path=str(test_file), file_hash=file_hash, before=2, contents="new_line\n" 58 | ) 59 | 60 | assert result["result"] == "ok" 61 | assert result["hash"] is not None 62 | 63 | # Verify the content 64 | content = test_file.read_text() 65 | assert content == "line1\nnew_line\nline2\nline3\n" 66 | 67 | 68 | @pytest.mark.asyncio 69 | async def test_insert_beyond_file_end(tmp_path: Path) -> None: 70 | """Test inserting text beyond the end of file.""" 71 | # Create a test file 72 | test_file = tmp_path / "test.txt" 73 | test_file.write_text("line1\nline2\nline3\n") 74 | 75 | # Initialize TextEditor 76 | editor = TextEditor() 77 | 78 | # Read file to get hash 79 | result = await editor.read_multiple_ranges( 80 | [{"file_path": str(test_file), "ranges": [{"start": 1}]}] 81 | ) 82 | file_hash = result[str(test_file)]["file_hash"] 83 | 84 | # Try to insert text after line 10 (file has only 3 lines) 85 | result = await editor.insert_text_file_contents( 86 | file_path=str(test_file), file_hash=file_hash, after=10, contents="new_line\n" 87 | ) 88 | 89 | assert result["result"] == "error" 90 | assert "beyond end of file" in result["reason"] 91 | 92 | 93 | @pytest.mark.asyncio 94 | async def test_file_not_found(tmp_path: Path) -> None: 95 | """Test inserting text into a non-existent file.""" 96 | # Initialize TextEditor 97 | editor = TextEditor() 98 | 99 | # Try to insert text into a non-existent file 100 | result = await editor.insert_text_file_contents( 101 | file_path=str(tmp_path / "nonexistent.txt"), 102 | file_hash="any_hash", 103 | after=1, 104 | contents="new_line\n", 105 | ) 106 | 107 | assert result["result"] == "error" 108 | assert "File not found" in result["reason"] 109 | 110 | 111 | @pytest.mark.asyncio 112 | async def test_hash_mismatch(tmp_path: Path) -> None: 113 | """Test inserting text with incorrect file hash.""" 114 | # Create a test file 115 | test_file = tmp_path / "test.txt" 116 | test_file.write_text("line1\nline2\nline3\n") 117 | 118 | # Initialize TextEditor 119 | editor = TextEditor() 120 | 121 | # Try to insert text with incorrect hash 122 | result = await editor.insert_text_file_contents( 123 | file_path=str(test_file), 124 | file_hash="incorrect_hash", 125 | after=1, 126 | contents="new_line\n", 127 | ) 128 | 129 | assert result["result"] == "error" 130 | assert "hash mismatch" in result["reason"] 131 | -------------------------------------------------------------------------------- /tests/test_insert_text_file_handler.py: -------------------------------------------------------------------------------- 1 | """Tests for InsertTextFileContentsHandler.""" 2 | 3 | import json 4 | 5 | import pytest 6 | 7 | from mcp_text_editor.handlers.insert_text_file_contents import ( 8 | InsertTextFileContentsHandler, 9 | ) 10 | from mcp_text_editor.text_editor import TextEditor 11 | 12 | 13 | @pytest.fixture 14 | def handler(): 15 | """Create handler instance.""" 16 | editor = TextEditor() 17 | return InsertTextFileContentsHandler(editor) 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_missing_path(handler): 22 | """Test handling of missing file_path argument.""" 23 | with pytest.raises(RuntimeError, match="Missing required argument: file_path"): 24 | await handler.run_tool({"file_hash": "hash", "contents": "content"}) 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_missing_hash(handler): 29 | """Test handling of missing file_hash argument.""" 30 | with pytest.raises(RuntimeError, match="Missing required argument: file_hash"): 31 | await handler.run_tool({"file_path": "/tmp/test.txt", "contents": "content"}) 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_missing_contents(handler): 36 | """Test handling of missing contents argument.""" 37 | with pytest.raises(RuntimeError, match="Missing required argument: contents"): 38 | await handler.run_tool({"file_path": "/tmp/test.txt", "file_hash": "hash"}) 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_relative_path(handler): 43 | """Test handling of relative file path.""" 44 | with pytest.raises(RuntimeError, match="File path must be absolute"): 45 | await handler.run_tool( 46 | { 47 | "file_path": "relative/path.txt", 48 | "file_hash": "hash", 49 | "contents": "content", 50 | "before": 1, 51 | } 52 | ) 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_neither_before_nor_after(handler): 57 | """Test handling of neither before nor after specified.""" 58 | with pytest.raises( 59 | RuntimeError, match="Exactly one of 'before' or 'after' must be specified" 60 | ): 61 | await handler.run_tool( 62 | {"file_path": "/tmp/test.txt", "file_hash": "hash", "contents": "content"} 63 | ) 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_both_before_and_after(handler): 68 | """Test handling of both before and after specified.""" 69 | with pytest.raises( 70 | RuntimeError, match="Exactly one of 'before' or 'after' must be specified" 71 | ): 72 | await handler.run_tool( 73 | { 74 | "file_path": "/tmp/test.txt", 75 | "file_hash": "hash", 76 | "contents": "content", 77 | "before": 1, 78 | "after": 2, 79 | } 80 | ) 81 | 82 | 83 | @pytest.mark.asyncio 84 | async def test_successful_insert_before(handler, tmp_path): 85 | """Test successful insert before line.""" 86 | test_file = tmp_path / "test.txt" 87 | test_file.write_text("line1\nline2\n") 88 | 89 | file_path = str(test_file) 90 | 91 | # Get the current hash 92 | content, _, _, init_hash, _, _ = await handler.editor.read_file_contents( 93 | file_path=file_path, start=1 94 | ) 95 | 96 | result = await handler.run_tool( 97 | { 98 | "file_path": file_path, 99 | "file_hash": init_hash, 100 | "contents": "new_line\n", 101 | "before": 2, 102 | } 103 | ) 104 | 105 | assert len(result) == 1 106 | assert result[0].type == "text" 107 | response_data = json.loads(result[0].text) 108 | assert response_data[file_path]["result"] == "ok" 109 | 110 | # Verify the content 111 | assert test_file.read_text() == "line1\nnew_line\nline2\n" 112 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | """Tests for data models.""" 2 | 3 | import pytest 4 | from pydantic import ValidationError 5 | 6 | from mcp_text_editor.models import ( 7 | EditFileOperation, 8 | EditPatch, 9 | EditResult, 10 | EditTextFileContentsRequest, 11 | FileRange, 12 | FileRanges, 13 | GetTextFileContentsRequest, 14 | GetTextFileContentsResponse, 15 | ) 16 | 17 | 18 | def test_get_text_file_contents_request(): 19 | """Test GetTextFileContentsRequest model.""" 20 | # Test with only required field 21 | request = GetTextFileContentsRequest(file_path="/path/to/file.txt") 22 | assert request.file_path == "/path/to/file.txt" 23 | assert request.start == 1 # Default value 24 | assert request.end is None # Default value 25 | 26 | # Test with all fields 27 | request = GetTextFileContentsRequest(file_path="/path/to/file.txt", start=5, end=10) 28 | assert request.file_path == "/path/to/file.txt" 29 | assert request.start == 5 30 | assert request.end == 10 31 | 32 | # Test validation error - missing required field 33 | with pytest.raises(ValidationError): 34 | GetTextFileContentsRequest() 35 | 36 | 37 | def test_get_text_file_contents_response(): 38 | """Test GetTextFileContentsResponse model.""" 39 | response = GetTextFileContentsResponse( 40 | contents="file content", start=1, end=10, hash="hash123" 41 | ) 42 | assert response.contents == "file content" 43 | assert response.start == 1 44 | assert response.end == 10 45 | assert response.hash == "hash123" 46 | 47 | # Test validation error - missing required fields 48 | with pytest.raises(ValidationError): 49 | GetTextFileContentsResponse() 50 | 51 | 52 | def test_edit_patch(): 53 | """Test EditPatch model.""" 54 | # Test that range_hash is required 55 | with pytest.raises(ValueError, match="range_hash is required"): 56 | EditPatch(contents="new content") 57 | with pytest.raises(ValueError, match="range_hash is required"): 58 | EditPatch(contents="new content", start=1) 59 | 60 | # Test append mode with empty range_hash 61 | patch = EditPatch(contents="new content", start=1, range_hash="") 62 | assert patch.contents == "new content" 63 | assert patch.start == 1 64 | assert patch.end is None 65 | 66 | # Test modification mode (requires end when range_hash is present) 67 | patch = EditPatch(start=5, end=10, contents="new content", range_hash="somehash") 68 | assert patch.start == 5 69 | assert patch.end == 10 70 | assert patch.contents == "new content" 71 | assert patch.range_hash == "somehash" 72 | 73 | # Test validation error - missing required field 74 | with pytest.raises(ValidationError): 75 | EditPatch() 76 | 77 | 78 | def test_edit_file_operation(): 79 | """Test EditFileOperation model.""" 80 | patches = [ 81 | EditPatch(contents="content1", range_hash=""), # append mode 82 | EditPatch(start=2, end=3, contents="content2", range_hash="somehash"), 83 | ] 84 | operation = EditFileOperation( 85 | path="/path/to/file.txt", hash="hash123", patches=patches 86 | ) 87 | assert operation.path == "/path/to/file.txt" 88 | assert operation.hash == "hash123" 89 | assert len(operation.patches) == 2 90 | assert operation.patches[0].contents == "content1" 91 | assert operation.patches[0].range_hash == "" # append mode 92 | assert operation.patches[1].start == 2 93 | assert operation.patches[1].range_hash == "somehash" # modification mode 94 | 95 | # Test validation error - missing required fields 96 | with pytest.raises(ValidationError): 97 | EditFileOperation() 98 | 99 | # Test validation error - invalid patches type 100 | with pytest.raises(ValidationError): 101 | EditFileOperation(path="/path/to/file.txt", hash="hash123", patches="invalid") 102 | 103 | 104 | def test_edit_result(): 105 | """Test EditResult model.""" 106 | # Test successful result 107 | result = EditResult(result="ok", hash="newhash123") 108 | assert result.result == "ok" 109 | assert result.hash == "newhash123" 110 | assert result.reason is None 111 | result_dict = result.to_dict() 112 | assert result_dict["result"] == "ok" 113 | assert result_dict["hash"] == "newhash123" 114 | assert "reason" not in result_dict 115 | 116 | # Test error result with reason 117 | result = EditResult( 118 | result="error", 119 | reason="hash mismatch", 120 | hash="currenthash123", 121 | ) 122 | assert result.result == "error" 123 | assert result.reason == "hash mismatch" 124 | assert result.hash is None 125 | result_dict = result.to_dict() 126 | assert result_dict["result"] == "error" 127 | assert result_dict["reason"] == "hash mismatch" 128 | assert "hash" not in result_dict 129 | 130 | # Test validation error - missing required fields 131 | with pytest.raises(ValidationError): 132 | EditResult() 133 | 134 | 135 | def test_edit_text_file_contents_request(): 136 | """Test EditTextFileContentsRequest model.""" 137 | # Test with single file operation 138 | request = EditTextFileContentsRequest( 139 | files=[ 140 | EditFileOperation( 141 | path="/path/to/file.txt", 142 | hash="hash123", 143 | patches=[EditPatch(contents="new content", range_hash="")], 144 | ) 145 | ] 146 | ) 147 | assert len(request.files) == 1 148 | assert request.files[0].path == "/path/to/file.txt" 149 | assert request.files[0].hash == "hash123" 150 | assert len(request.files[0].patches) == 1 151 | assert request.files[0].patches[0].contents == "new content" 152 | 153 | # Test with multiple file operations 154 | request = EditTextFileContentsRequest( 155 | files=[ 156 | EditFileOperation( 157 | path="/path/to/file1.txt", 158 | hash="hash123", 159 | patches=[EditPatch(contents="content1", range_hash="")], 160 | ), 161 | EditFileOperation( 162 | path="/path/to/file2.txt", 163 | hash="hash456", 164 | patches=[EditPatch(start=2, contents="content2", range_hash="")], 165 | ), 166 | ] 167 | ) 168 | assert len(request.files) == 2 169 | assert request.files[0].path == "/path/to/file1.txt" 170 | assert request.files[1].path == "/path/to/file2.txt" 171 | 172 | # Test validation error - missing required field 173 | with pytest.raises(ValidationError): 174 | EditTextFileContentsRequest() 175 | 176 | 177 | def test_edit_result_to_dict(): 178 | """Test EditResult's to_dict method.""" 179 | # Test successful result 180 | result = EditResult(result="ok", hash="newhash123") 181 | result_dict = result.to_dict() 182 | assert result_dict == {"result": "ok", "hash": "newhash123"} 183 | 184 | # Test error result 185 | result = EditResult( 186 | result="error", 187 | reason="hash mismatch", 188 | hash="currenthash123", 189 | ) 190 | result_dict = result.to_dict() 191 | assert result_dict == {"result": "error", "reason": "hash mismatch"} 192 | 193 | 194 | def test_file_range(): 195 | """Test FileRange model.""" 196 | # Test with only required field 197 | range_ = FileRange(start=1) 198 | assert range_.start == 1 199 | assert range_.end is None # Default value 200 | 201 | # Test with all fields 202 | range_ = FileRange(start=5, end=10) 203 | assert range_.start == 5 204 | assert range_.end == 10 205 | 206 | # Test validation error - missing required field 207 | with pytest.raises(ValidationError): 208 | FileRange() 209 | 210 | 211 | def test_file_ranges(): 212 | """Test FileRanges model.""" 213 | ranges = [ 214 | FileRange(start=1), 215 | FileRange(start=5, end=10), 216 | ] 217 | file_ranges = FileRanges(file_path="/path/to/file.txt", ranges=ranges) 218 | assert file_ranges.file_path == "/path/to/file.txt" 219 | assert len(file_ranges.ranges) == 2 220 | assert file_ranges.ranges[0].start == 1 221 | assert file_ranges.ranges[0].end is None 222 | assert file_ranges.ranges[1].start == 5 223 | assert file_ranges.ranges[1].end == 10 224 | 225 | # Test validation error - missing required fields 226 | with pytest.raises(ValidationError): 227 | FileRanges() 228 | 229 | # Test validation error - invalid ranges type 230 | with pytest.raises(ValidationError): 231 | FileRanges(file_path="/path/to/file.txt", ranges="invalid") 232 | -------------------------------------------------------------------------------- /tests/test_patch_text_file.py: -------------------------------------------------------------------------------- 1 | """Test module for patch_text_file.""" 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from mcp_text_editor.handlers.patch_text_file_contents import ( 8 | PatchTextFileContentsHandler, 9 | ) 10 | from mcp_text_editor.text_editor import TextEditor 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_patch_text_file_middle(tmp_path): 15 | """Test patching text in the middle of the file.""" 16 | # Create a test file 17 | file_path = os.path.join(tmp_path, "test.txt") 18 | editor = TextEditor() 19 | 20 | # Create initial content 21 | content = "line1\nline2\nline3\nline4\nline5\n" 22 | with open(file_path, "w", encoding="utf-8") as f: 23 | f.write(content) 24 | 25 | # Get file hash and range hash 26 | file_info = await editor.read_multiple_ranges( 27 | [{"file_path": str(file_path), "ranges": [{"start": 2, "end": 3}]}] 28 | ) 29 | 30 | # Extract file and range hashes 31 | file_content = file_info[str(file_path)] 32 | file_hash = file_content["file_hash"] 33 | range_hash = file_content["ranges"][0]["range_hash"] 34 | 35 | # Patch the file 36 | new_content = "new line2\nnew line3\n" 37 | patch = { 38 | "start": 2, 39 | "end": 3, # changed from 4 to 3 since we only want to replace lines 2-3 40 | "contents": new_content, 41 | "range_hash": range_hash, 42 | } 43 | result = await editor.edit_file_contents( 44 | str(file_path), 45 | file_hash, 46 | [patch], 47 | ) 48 | 49 | # Verify the patch was successful 50 | assert result["result"] == "ok" 51 | with open(file_path, "r", encoding="utf-8") as f: 52 | updated_content = f.read() 53 | assert updated_content == "line1\nnew line2\nnew line3\nline4\nline5\n" 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_patch_text_file_empty_content(tmp_path): 58 | """Test patching with empty content suggests using delete_text_file_contents.""" 59 | # Create a test file 60 | file_path = os.path.join(tmp_path, "test.txt") 61 | editor = TextEditor() 62 | 63 | # Create initial content 64 | content = "line1\nline2\nline3\nline4\nline5\n" 65 | with open(file_path, "w", encoding="utf-8") as f: 66 | f.write(content) 67 | 68 | # Get file hash and range hash 69 | file_info = await editor.read_multiple_ranges( 70 | [{"file_path": str(file_path), "ranges": [{"start": 2, "end": 3}]}] 71 | ) 72 | 73 | # Extract file and range hashes 74 | file_content = file_info[str(file_path)] 75 | file_hash = file_content["file_hash"] 76 | range_hash = file_content["ranges"][0]["range_hash"] 77 | 78 | # Patch the file with empty content 79 | patch = { 80 | "start": 2, 81 | "end": 3, 82 | "contents": "", 83 | "range_hash": range_hash, 84 | } 85 | result = await editor.edit_file_contents( 86 | str(file_path), 87 | file_hash, 88 | [patch], 89 | ) 90 | 91 | # Verify that the operation suggests using delete_text_file_contents 92 | assert result["result"] == "ok" 93 | assert result["file_hash"] == file_hash 94 | assert "delete_text_file_contents" in result["hint"] 95 | assert result["suggestion"] == "delete" 96 | 97 | # Verify file content remains unchanged 98 | with open(file_path, "r", encoding="utf-8") as f: 99 | updated_content = f.read() 100 | assert updated_content == content 101 | 102 | 103 | @pytest.mark.asyncio 104 | async def test_patch_text_file_errors(tmp_path): 105 | """Test error handling when patching a file.""" 106 | editor = TextEditor() 107 | handler = PatchTextFileContentsHandler(editor) 108 | file_path = os.path.join(tmp_path, "test.txt") 109 | non_existent_file = os.path.join(tmp_path, "non_existent.txt") 110 | relative_path = "test.txt" 111 | 112 | # Test missing file_path 113 | with pytest.raises(RuntimeError, match="Missing required argument: file_path"): 114 | await handler.run_tool({"patches": [], "file_hash": "hash"}) 115 | 116 | # Test missing file_hash 117 | with pytest.raises(RuntimeError, match="Missing required argument: file_hash"): 118 | await handler.run_tool({"file_path": file_path, "patches": []}) 119 | 120 | # Test missing patches 121 | with pytest.raises(RuntimeError, match="Missing required argument: patches"): 122 | await handler.run_tool({"file_path": file_path, "file_hash": "hash"}) 123 | 124 | # Test non-absolute file path 125 | with pytest.raises(RuntimeError, match="File path must be absolute"): 126 | await handler.run_tool( 127 | {"file_path": relative_path, "file_hash": "hash", "patches": []} 128 | ) 129 | 130 | # Test non-existent file 131 | with pytest.raises(RuntimeError, match="File does not exist"): 132 | await handler.run_tool( 133 | {"file_path": non_existent_file, "file_hash": "hash", "patches": []} 134 | ) 135 | 136 | 137 | @pytest.mark.asyncio 138 | async def test_patch_text_file_unexpected_error(tmp_path, mocker): 139 | """Test handling of unexpected errors during patching.""" 140 | editor = TextEditor() 141 | handler = PatchTextFileContentsHandler(editor) 142 | file_path = os.path.join(tmp_path, "test.txt") 143 | 144 | # Create a test file 145 | with open(file_path, "w", encoding="utf-8") as f: 146 | f.write("test content\n") 147 | 148 | # Mock edit_file_contents to raise an unexpected error 149 | async def mock_edit_file_contents(*args, **kwargs): 150 | raise Exception("Unexpected test error") 151 | 152 | # Patch the editor's method using mocker 153 | mocker.patch.object(editor, "edit_file_contents", mock_edit_file_contents) 154 | 155 | # Try to patch the file with the mocked error 156 | with pytest.raises( 157 | RuntimeError, match="Error processing request: Unexpected test error" 158 | ): 159 | await handler.run_tool( 160 | { 161 | "file_path": file_path, 162 | "file_hash": "dummy_hash", 163 | "patches": [ 164 | { 165 | "start": 1, 166 | "contents": "new content\n", 167 | "range_hash": "dummy_hash", 168 | } 169 | ], 170 | } 171 | ) 172 | 173 | 174 | @pytest.mark.asyncio 175 | async def test_patch_text_file_overlapping(tmp_path): 176 | """Test patching text with overlapping ranges should fail.""" 177 | # Create a test file 178 | file_path = os.path.join(tmp_path, "test.txt") 179 | editor = TextEditor() 180 | 181 | # Create initial content 182 | content = "line1\nline2\nline3\nline4\nline5\n" 183 | with open(file_path, "w", encoding="utf-8") as f: 184 | f.write(content) 185 | 186 | # Get file hash and range hash for first patch 187 | file_info = await editor.read_multiple_ranges( 188 | [{"file_path": str(file_path), "ranges": [{"start": 2, "end": 3}]}] 189 | ) 190 | 191 | # Extract hashes for first patch 192 | file_content = file_info[str(file_path)] 193 | file_hash = file_content["file_hash"] 194 | range_hash_1 = file_content["ranges"][0]["range_hash"] 195 | 196 | # Get range hash for second patch 197 | file_info = await editor.read_multiple_ranges( 198 | [{"file_path": str(file_path), "ranges": [{"start": 3, "end": 4}]}] 199 | ) 200 | range_hash_2 = file_info[str(file_path)]["ranges"][0]["range_hash"] 201 | 202 | # Create overlapping patches 203 | patches = [ 204 | { 205 | "start": 2, 206 | "end": 3, 207 | "contents": "new line2\nnew line3\n", 208 | "range_hash": range_hash_1, 209 | }, 210 | { 211 | "start": 3, 212 | "end": 4, 213 | "contents": "another new line3\nnew line4\n", 214 | "range_hash": range_hash_2, 215 | }, 216 | ] 217 | 218 | # Try to apply overlapping patches 219 | result = await editor.edit_file_contents( 220 | str(file_path), 221 | file_hash, 222 | patches, 223 | ) 224 | 225 | # Verify that the operation failed due to overlapping patches 226 | assert result["result"] == "error" 227 | 228 | 229 | @pytest.mark.asyncio 230 | async def test_patch_text_file_new_file_hint(tmp_path): 231 | """Test patching a new file suggests using append_text_file_contents.""" 232 | file_path = os.path.join(tmp_path, "new.txt") 233 | editor = TextEditor() 234 | 235 | # Try to patch a new file 236 | patch = { 237 | "start": 1, 238 | "contents": "new content\n", 239 | "range_hash": "", # Empty hash for new file 240 | } 241 | result = await editor.edit_file_contents( 242 | str(file_path), 243 | "", # Empty hash for new file 244 | [patch], 245 | ) 246 | 247 | # Verify that the operation suggests using append_text_file_contents 248 | assert result["result"] == "ok" 249 | assert result["suggestion"] == "append" 250 | assert "append_text_file_contents" in result["hint"] 251 | 252 | 253 | @pytest.mark.asyncio 254 | async def test_patch_text_file_append_hint(tmp_path): 255 | """Test patching beyond file end suggests using append_text_file_contents.""" 256 | file_path = os.path.join(tmp_path, "test.txt") 257 | editor = TextEditor() 258 | 259 | # Create initial content 260 | content = "line1\nline2\n" 261 | with open(file_path, "w", encoding="utf-8") as f: 262 | f.write(content) 263 | 264 | # Get file hash 265 | file_info = await editor.read_multiple_ranges( 266 | [{"file_path": str(file_path), "ranges": [{"start": 1, "end": 2}]}] 267 | ) 268 | file_hash = file_info[str(file_path)]["file_hash"] 269 | 270 | # Try to patch beyond file end 271 | patch = { 272 | "start": 5, # Beyond file end 273 | "contents": "new line\n", 274 | "range_hash": "", # Empty hash for insertion 275 | } 276 | result = await editor.edit_file_contents( 277 | str(file_path), 278 | file_hash, 279 | [patch], 280 | ) 281 | 282 | # Verify the suggestion to use append_text_file_contents 283 | assert result["result"] == "ok" 284 | assert result["suggestion"] == "append" 285 | assert "append_text_file_contents" in result["hint"] 286 | 287 | 288 | @pytest.mark.asyncio 289 | async def test_patch_text_file_insert_hint(tmp_path): 290 | """Test patching with insertion suggests using insert_text_file_contents.""" 291 | file_path = os.path.join(tmp_path, "test.txt") 292 | editor = TextEditor() 293 | 294 | # Create initial content 295 | content = "line1\nline2\n" 296 | with open(file_path, "w", encoding="utf-8") as f: 297 | f.write(content) 298 | 299 | # Get file hash 300 | file_info = await editor.read_multiple_ranges( 301 | [{"file_path": str(file_path), "ranges": [{"start": 1, "end": 2}]}] 302 | ) 303 | file_hash = file_info[str(file_path)]["file_hash"] 304 | 305 | # Try to insert at start 306 | patch = { 307 | "start": 1, 308 | "contents": "new line\n", 309 | "range_hash": "", # Empty hash for insertion 310 | } 311 | result = await editor.edit_file_contents( 312 | str(file_path), 313 | file_hash, 314 | [patch], 315 | ) 316 | 317 | # Verify the suggestion to use insert_text_file_contents 318 | assert result["result"] == "ok" 319 | assert result["suggestion"] == "insert" 320 | assert "insert_text_file_contents" in result["hint"] 321 | 322 | 323 | @pytest.mark.asyncio 324 | async def test_patch_text_file_hash_mismatch_hint(tmp_path): 325 | """Test patching with wrong hash suggests using get_text_file_contents.""" 326 | file_path = os.path.join(tmp_path, "test.txt") 327 | editor = TextEditor() 328 | 329 | # Create initial content 330 | content = "line1\nline2\n" 331 | with open(file_path, "w", encoding="utf-8") as f: 332 | f.write(content) 333 | 334 | # Try to patch with wrong file hash 335 | patch = { 336 | "start": 1, 337 | "contents": "new line\n", 338 | "range_hash": "", 339 | } 340 | result = await editor.edit_file_contents( 341 | str(file_path), 342 | "wrong_hash", 343 | [patch], 344 | ) 345 | 346 | # Verify the suggestion to use get_text_file_contents 347 | assert result["result"] == "error" 348 | assert "get_text_file_contents" in result["hint"] 349 | -------------------------------------------------------------------------------- /tests/test_patch_text_file_end_none.py: -------------------------------------------------------------------------------- 1 | """Test module for patch_text_file with end=None.""" 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from mcp_text_editor.text_editor import TextEditor 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_patch_text_file_end_none(tmp_path): 12 | """Test patching text file with end=None.""" 13 | # Create a test file 14 | file_path = os.path.join(tmp_path, "test.txt") 15 | editor = TextEditor() 16 | 17 | # Create initial content 18 | content = "line1\nline2\nline3\nline4\nline5\n" 19 | with open(file_path, "w", encoding="utf-8") as f: 20 | f.write(content) 21 | 22 | # Get file hash and range hash 23 | file_info = await editor.read_multiple_ranges( 24 | [ 25 | { 26 | "file_path": str(file_path), 27 | "ranges": [{"start": 2, "end": None}], # Test with end=None 28 | } 29 | ] 30 | ) 31 | 32 | # Extract file and range hashes 33 | file_content = file_info[str(file_path)] 34 | file_hash = file_content["file_hash"] 35 | range_hash = file_content["ranges"][0]["range_hash"] 36 | 37 | # Patch the file 38 | new_content = "new line2\nnew line3\nnew line4\nnew line5\n" 39 | patch = { 40 | "start": 2, 41 | "end": None, # Test with end=None 42 | "contents": new_content, 43 | "range_hash": range_hash, 44 | } 45 | result = await editor.edit_file_contents( 46 | str(file_path), 47 | file_hash, 48 | [patch], 49 | ) 50 | 51 | # Verify the patch was successful 52 | assert result["result"] == "ok" 53 | with open(file_path, "r", encoding="utf-8") as f: 54 | updated_content = f.read() 55 | assert updated_content == "line1\nnew line2\nnew line3\nnew line4\nnew line5\n" 56 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | """Tests for the MCP Text Editor Server.""" 2 | 3 | import json 4 | from pathlib import Path 5 | from typing import List 6 | 7 | import pytest 8 | from mcp.server import stdio 9 | from mcp.types import TextContent, Tool 10 | from pytest_mock import MockerFixture 11 | 12 | from mcp_text_editor.server import ( 13 | GetTextFileContentsHandler, 14 | app, 15 | append_file_handler, 16 | call_tool, 17 | create_file_handler, 18 | delete_contents_handler, 19 | get_contents_handler, 20 | insert_file_handler, 21 | list_tools, 22 | main, 23 | patch_file_handler, 24 | ) 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_list_tools(): 29 | """Test tool listing.""" 30 | tools: List[Tool] = await list_tools() 31 | assert len(tools) == 6 32 | 33 | # Verify GetTextFileContents tool 34 | get_contents_tool = next( 35 | (tool for tool in tools if tool.name == "get_text_file_contents"), None 36 | ) 37 | assert get_contents_tool is not None 38 | assert "file" in get_contents_tool.description.lower() 39 | assert "contents" in get_contents_tool.description.lower() 40 | 41 | 42 | @pytest.mark.asyncio 43 | async def test_get_contents_empty_files(): 44 | """Test get_contents handler with empty files list.""" 45 | arguments = {"files": []} 46 | result = await get_contents_handler.run_tool(arguments) 47 | assert len(result) == 1 48 | assert result[0].type == "text" 49 | # Should return empty JSON object 50 | assert json.loads(result[0].text) == {} 51 | 52 | 53 | @pytest.mark.asyncio 54 | async def test_unknown_tool_handler(): 55 | """Test handling of unknown tool name.""" 56 | with pytest.raises(ValueError) as excinfo: 57 | await call_tool("unknown_tool", {}) 58 | assert "Unknown tool: unknown_tool" in str(excinfo.value) 59 | 60 | 61 | @pytest.mark.asyncio 62 | async def test_get_contents_handler(test_file): 63 | """Test GetTextFileContents handler.""" 64 | args = {"files": [{"file_path": test_file, "ranges": [{"start": 1, "end": 3}]}]} 65 | result = await get_contents_handler.run_tool(args) 66 | assert len(result) == 1 67 | assert isinstance(result[0], TextContent) 68 | content = json.loads(result[0].text) 69 | assert test_file in content 70 | range_result = content[test_file]["ranges"][0] 71 | assert "content" in range_result 72 | assert "start" in range_result 73 | assert "end" in range_result 74 | assert "file_hash" in content[test_file] 75 | assert "total_lines" in range_result 76 | assert "content_size" in range_result 77 | 78 | 79 | @pytest.mark.asyncio 80 | async def test_get_contents_handler_invalid_file(test_file): 81 | """Test GetTextFileContents handler with invalid file.""" 82 | # Convert relative path to absolute 83 | nonexistent_path = str(Path("nonexistent.txt").absolute()) 84 | args = {"files": [{"file_path": nonexistent_path, "ranges": [{"start": 1}]}]} 85 | with pytest.raises(RuntimeError) as exc_info: 86 | await get_contents_handler.run_tool(args) 87 | assert "File not found" in str(exc_info.value) 88 | 89 | 90 | @pytest.mark.asyncio 91 | async def test_call_tool_get_contents(test_file): 92 | """Test call_tool with GetTextFileContents.""" 93 | args = {"files": [{"file_path": test_file, "ranges": [{"start": 1, "end": 3}]}]} 94 | result = await call_tool("get_text_file_contents", args) 95 | assert len(result) == 1 96 | assert isinstance(result[0], TextContent) 97 | content = json.loads(result[0].text) 98 | assert test_file in content 99 | range_result = content[test_file]["ranges"][0] 100 | assert "content" in range_result 101 | assert "start" in range_result 102 | assert "end" in range_result 103 | assert "file_hash" in content[test_file] 104 | assert "total_lines" in range_result 105 | assert "content_size" in range_result 106 | 107 | 108 | @pytest.mark.asyncio 109 | async def test_call_tool_unknown(): 110 | """Test call_tool with unknown tool.""" 111 | with pytest.raises(ValueError) as exc_info: 112 | await call_tool("UnknownTool", {}) 113 | assert "Unknown tool" in str(exc_info.value) 114 | 115 | 116 | @pytest.mark.asyncio 117 | async def test_call_tool_error_handling(): 118 | """Test call_tool error handling.""" 119 | # Test with invalid arguments 120 | with pytest.raises(RuntimeError) as exc_info: 121 | await call_tool("get_text_file_contents", {"invalid": "args"}) 122 | assert "Missing required argument" in str(exc_info.value) 123 | 124 | # Convert relative path to absolute 125 | nonexistent_path = str(Path("nonexistent.txt").absolute()) 126 | with pytest.raises(RuntimeError) as exc_info: 127 | await call_tool( 128 | "get_text_file_contents", 129 | {"files": [{"file_path": nonexistent_path, "ranges": [{"start": 1}]}]}, 130 | ) 131 | assert "File not found" in str(exc_info.value) 132 | 133 | 134 | @pytest.mark.asyncio 135 | async def test_get_contents_handler_legacy_missing_args(): 136 | """Test GetTextFileContents handler with legacy single file request missing arguments.""" 137 | with pytest.raises(RuntimeError) as exc_info: 138 | await get_contents_handler.run_tool({}) 139 | assert "Missing required argument: 'files'" in str(exc_info.value) 140 | 141 | 142 | @pytest.mark.asyncio 143 | async def test_main_stdio_server_error(mocker: MockerFixture): 144 | """Test main function with stdio_server error.""" 145 | # Mock the stdio_server to raise an exception 146 | mock_stdio = mocker.patch.object(stdio, "stdio_server") 147 | mock_stdio.side_effect = Exception("Stdio server error") 148 | 149 | with pytest.raises(Exception) as exc_info: 150 | await main() 151 | assert "Stdio server error" in str(exc_info.value) 152 | 153 | 154 | @pytest.mark.asyncio 155 | async def test_main_run_error(mocker: MockerFixture): 156 | """Test main function with app.run error.""" 157 | # Mock the stdio_server context manager 158 | mock_stdio = mocker.patch.object(stdio, "stdio_server") 159 | mock_context = mocker.MagicMock() 160 | mock_context.__aenter__.return_value = (mocker.MagicMock(), mocker.MagicMock()) 161 | mock_stdio.return_value = mock_context 162 | 163 | # Mock app.run to raise an exception 164 | mock_run = mocker.patch.object(app, "run") 165 | mock_run.side_effect = Exception("App run error") 166 | 167 | with pytest.raises(Exception) as exc_info: 168 | await main() 169 | assert "App run error" in str(exc_info.value) 170 | 171 | 172 | @pytest.mark.asyncio 173 | async def test_get_contents_relative_path(): 174 | """Test GetTextFileContents with relative path.""" 175 | handler = GetTextFileContentsHandler() 176 | with pytest.raises(RuntimeError, match="File path must be absolute:.*"): 177 | await handler.run_tool( 178 | { 179 | "files": [ 180 | {"file_path": "relative/path/file.txt", "ranges": [{"start": 1}]} 181 | ] 182 | } 183 | ) 184 | 185 | 186 | @pytest.mark.asyncio 187 | async def test_get_contents_absolute_path(): 188 | """Test GetTextFileContents with absolute path.""" 189 | handler = GetTextFileContentsHandler() 190 | abs_path = str(Path("/absolute/path/file.txt").absolute()) 191 | 192 | # Define mock as async function 193 | async def mock_read_multiple_ranges(*args, **kwargs): 194 | return [] 195 | 196 | # Set up mock 197 | handler.editor.read_multiple_ranges = mock_read_multiple_ranges 198 | 199 | result = await handler.run_tool( 200 | {"files": [{"file_path": abs_path, "ranges": [{"start": 1}]}]} 201 | ) 202 | assert isinstance(result[0], TextContent) 203 | 204 | 205 | @pytest.mark.asyncio 206 | async def test_call_tool_general_exception(): 207 | """Test call_tool with a general exception.""" 208 | # Patch get_contents_handler.run_tool to raise a general exception 209 | original_run_tool = get_contents_handler.run_tool 210 | 211 | async def mock_run_tool(args): 212 | raise Exception("Unexpected error") 213 | 214 | try: 215 | get_contents_handler.run_tool = mock_run_tool 216 | with pytest.raises(RuntimeError) as exc_info: 217 | await call_tool("get_text_file_contents", {"files": []}) 218 | assert "Error executing command: Unexpected error" in str(exc_info.value) 219 | finally: 220 | # Restore original method 221 | get_contents_handler.run_tool = original_run_tool 222 | 223 | 224 | @pytest.mark.asyncio 225 | async def test_call_tool_all_handlers(mocker: MockerFixture): 226 | """Test call_tool with all handlers.""" 227 | # Mock run_tool for each handler 228 | handlers = [ 229 | create_file_handler, 230 | append_file_handler, 231 | delete_contents_handler, 232 | insert_file_handler, 233 | patch_file_handler, 234 | ] 235 | 236 | # Setup mocks for all handlers 237 | async def mock_run_tool(args): 238 | return [TextContent(text="mocked response", type="text")] 239 | 240 | for handler in handlers: 241 | mock = mocker.patch.object(handler, "run_tool") 242 | mock.side_effect = mock_run_tool 243 | 244 | # Test each handler 245 | for handler in handlers: 246 | result = await call_tool(handler.name, {"test": "args"}) 247 | assert len(result) == 1 248 | assert isinstance(result[0], TextContent) 249 | assert result[0].text == "mocked response" 250 | -------------------------------------------------------------------------------- /tests/test_service.py: -------------------------------------------------------------------------------- 1 | """Tests for core service logic.""" 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from mcp_text_editor.models import EditFileOperation, EditPatch, EditResult 8 | from mcp_text_editor.service import TextEditorService 9 | 10 | 11 | @pytest.fixture 12 | def service(): 13 | """Create TextEditorService instance.""" 14 | return TextEditorService() 15 | 16 | 17 | def test_calculate_hash(service): 18 | """Test hash calculation.""" 19 | content = "test content" 20 | hash1 = service.calculate_hash(content) 21 | hash2 = service.calculate_hash(content) 22 | assert hash1 == hash2 23 | assert isinstance(hash1, str) 24 | assert len(hash1) == 64 # SHA-256 hash length 25 | 26 | 27 | def test_read_file_contents(service, test_file): 28 | """Test reading file contents.""" 29 | # Test reading entire file 30 | content, start, end = service.read_file_contents(test_file) 31 | assert content == "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n" 32 | assert start == 1 33 | assert end == 5 34 | 35 | # Test reading specific lines 36 | content, start, end = service.read_file_contents(test_file, start=2, end=4) 37 | assert content == "Line 2\nLine 3\nLine 4\n" 38 | assert start == 2 39 | assert end == 4 40 | 41 | 42 | def test_read_file_contents_invalid_file(service): 43 | """Test reading non-existent file.""" 44 | with pytest.raises(FileNotFoundError): 45 | service.read_file_contents("nonexistent.txt") 46 | 47 | 48 | def test_validate_patches(service): 49 | """Test patch validation.""" 50 | # Valid patches 51 | patches = [ 52 | EditPatch(start=1, end=2, contents="content1", range_hash="hash1"), 53 | EditPatch(start=3, end=4, contents="content2", range_hash="hash2"), 54 | ] 55 | assert service.validate_patches(patches, 5) is True 56 | 57 | # Overlapping patches 58 | patches = [ 59 | EditPatch(start=1, end=3, contents="content1", range_hash="hash1"), 60 | EditPatch(start=2, end=4, contents="content2", range_hash="hash2"), 61 | ] 62 | assert service.validate_patches(patches, 5) is False 63 | 64 | # Out of bounds patches 65 | patches = [EditPatch(start=1, end=10, contents="content1", range_hash="hash1")] 66 | assert service.validate_patches(patches, 5) is False 67 | 68 | 69 | def test_edit_file_contents(service, tmp_path): 70 | """Test editing file contents.""" 71 | # Create test file 72 | test_file = tmp_path / "edit_test.txt" 73 | test_content = "line1\nline2\nline3\n" 74 | test_file.write_text(test_content) 75 | file_path = str(test_file) 76 | 77 | # Calculate initial hash 78 | initial_hash = service.calculate_hash(test_content) 79 | 80 | # Create edit operation 81 | operation = EditFileOperation( 82 | path=file_path, 83 | hash=initial_hash, 84 | patches=[EditPatch(start=2, end=2, contents="new line2", range_hash="hash1")], 85 | ) 86 | 87 | # Apply edit 88 | result = service.edit_file_contents(file_path, operation) 89 | assert file_path in result 90 | edit_result = result[file_path] 91 | assert isinstance(edit_result, EditResult) 92 | assert edit_result.result == "ok" 93 | 94 | # Verify changes 95 | new_content = test_file.read_text() 96 | assert "new line2" in new_content 97 | 98 | 99 | def test_edit_file_contents_hash_mismatch(service, tmp_path): 100 | """Test editing with hash mismatch.""" 101 | # Create test file 102 | test_file = tmp_path / "hash_mismatch_test.txt" 103 | test_content = "line1\nline2\nline3\n" 104 | test_file.write_text(test_content) 105 | file_path = str(test_file) 106 | 107 | # Create edit operation with incorrect hash 108 | operation = EditFileOperation( 109 | path=file_path, 110 | hash="incorrect_hash", 111 | patches=[EditPatch(start=2, end=2, contents="new line2", range_hash="hash1")], 112 | ) 113 | 114 | # Attempt edit 115 | result = service.edit_file_contents(file_path, operation) 116 | assert file_path in result 117 | edit_result = result[file_path] 118 | assert edit_result.result == "error" 119 | assert "hash mismatch" in edit_result.reason.lower() 120 | 121 | 122 | def test_edit_file_contents_invalid_patches(service, tmp_path): 123 | """Test editing with invalid patches.""" 124 | # Create test file 125 | test_file = tmp_path / "invalid_patches_test.txt" 126 | test_content = "line1\nline2\nline3\n" 127 | test_file.write_text(test_content) 128 | file_path = str(test_file) 129 | 130 | # Calculate initial hash 131 | initial_hash = service.calculate_hash(test_content) 132 | 133 | # Create edit operation with invalid patches 134 | operation = EditFileOperation( 135 | path=file_path, 136 | hash=initial_hash, 137 | patches=[ 138 | EditPatch( 139 | start=1, end=10, contents="new content", range_hash="hash1" 140 | ) # Beyond file length 141 | ], 142 | ) 143 | 144 | # Attempt edit 145 | result = service.edit_file_contents(file_path, operation) 146 | assert file_path in result 147 | edit_result = result[file_path] 148 | assert edit_result.result == "error" 149 | assert "invalid patch" in edit_result.reason.lower() 150 | 151 | 152 | def test_edit_file_contents_file_error(service): 153 | """Test editing with file error.""" 154 | file_path = "nonexistent.txt" 155 | # Attempt to edit non-existent file 156 | operation = EditFileOperation( 157 | path=file_path, 158 | hash="any_hash", 159 | patches=[EditPatch(contents="new content", range_hash="")], 160 | ) 161 | 162 | # Attempt edit 163 | result = service.edit_file_contents(file_path, operation) 164 | assert file_path in result 165 | edit_result = result[file_path] 166 | assert edit_result.result == "error" 167 | assert "no such file" in edit_result.reason.lower() 168 | 169 | 170 | def test_edit_file_unexpected_error(service, tmp_path): 171 | """Test handling of unexpected errors during file editing.""" 172 | # Setup test file and operation 173 | test_file = str(tmp_path / "error_test.txt") 174 | operation = EditFileOperation( 175 | path=test_file, 176 | hash="dummy_hash", 177 | patches=[EditPatch(contents="test content\n", start=1, range_hash="")], 178 | ) 179 | 180 | # Try to edit non-existent file 181 | result = service.edit_file_contents(test_file, operation) 182 | edit_result = result[test_file] 183 | 184 | # Verify error handling 185 | assert edit_result.result == "error" 186 | assert "no such file" in edit_result.reason.lower() 187 | assert edit_result.hash is None 188 | 189 | 190 | def test_edit_file_contents_permission_error(service, tmp_path): 191 | """Test handling of permission errors during file editing.""" 192 | # Create test file 193 | test_file = tmp_path / "general_error_test.txt" 194 | test_content = "line1\nline2\nline3\n" 195 | test_file.write_text(test_content) 196 | file_path = str(test_file) 197 | 198 | # Make the file read-only to cause a permission error 199 | os.chmod(file_path, 0o444) 200 | 201 | # Create edit operation 202 | operation = EditFileOperation( 203 | path=file_path, 204 | hash=service.calculate_hash(test_content), 205 | patches=[EditPatch(start=2, end=2, contents="new line2", range_hash="hash1")], 206 | ) 207 | 208 | # Attempt edit 209 | result = service.edit_file_contents(file_path, operation) 210 | edit_result = result[file_path] 211 | 212 | assert edit_result.result == "error" 213 | assert "permission denied" in edit_result.reason.lower() 214 | assert edit_result.hash is None 215 | 216 | # Clean up 217 | os.chmod(file_path, 0o644) 218 | 219 | 220 | def test_edit_file_contents_general_exception(service, mocker): 221 | """Test handling of general exceptions during file editing.""" 222 | test_file = "test.txt" 223 | operation = EditFileOperation( 224 | path=test_file, 225 | hash="hash123", 226 | patches=[EditPatch(contents="new content", start=1, range_hash="")], 227 | ) 228 | 229 | # Mock edit_file to raise an exception 230 | # Create a test file 231 | with open(test_file, "w") as f: 232 | f.write("test content\n") 233 | 234 | try: 235 | # Mock os.path.exists to return True 236 | mocker.patch("os.path.exists", return_value=True) 237 | # Mock open to raise an exception 238 | mocker.patch( 239 | "builtins.open", 240 | side_effect=Exception("Unexpected error during file operation"), 241 | ) 242 | 243 | result = service.edit_file_contents(test_file, operation) 244 | edit_result = result[test_file] 245 | 246 | assert edit_result.result == "error" 247 | assert "unexpected error" in edit_result.reason.lower() 248 | 249 | finally: 250 | # Clean up 251 | import os 252 | 253 | mocker.stopall() 254 | if os.path.exists(test_file): 255 | os.remove(test_file) 256 | assert edit_result.hash is None 257 | --------------------------------------------------------------------------------