├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── install.sh ├── pyproject.toml ├── setup.py ├── src └── text_editor │ ├── __init__.py │ └── server.py ├── system_prompt.md ├── tests ├── conftest.py └── test_server.py └── uv.lock /.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 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Python 3.10 slim as base image (MCP requires Python >=3.10) 2 | FROM python:3.10-slim 3 | 4 | # Set environment variables 5 | ENV PYTHONUNBUFFERED=1 \ 6 | PYTHONDONTWRITEBYTECODE=1 \ 7 | PIP_NO_CACHE_DIR=1 \ 8 | PIP_DISABLE_PIP_VERSION_CHECK=1 \ 9 | NODE_VERSION=18 10 | 11 | # Set work directory 12 | WORKDIR /app 13 | 14 | # Install system dependencies including Node.js 15 | RUN apt-get update && apt-get install -y \ 16 | git \ 17 | curl \ 18 | && curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \ 19 | && apt-get install -y nodejs \ 20 | && rm -rf /var/lib/apt/lists/* 21 | 22 | # Copy pyproject.toml first to install dependencies 23 | COPY pyproject.toml ./ 24 | 25 | # Install core dependencies (correct MCP package with CLI tools + additional dependencies) 26 | RUN pip install --no-cache-dir black fastmcp duckdb 27 | 28 | # Install optional dependencies for full functionality 29 | RUN pip install --no-cache-dir pytest pytest-asyncio pytest-cov 30 | 31 | # Copy remaining project files 32 | COPY README.md ./ 33 | COPY LICENSE ./ 34 | 35 | # Copy source code 36 | COPY src/ ./src/ 37 | 38 | # Add the src directory to Python path so modules can be imported 39 | ENV PYTHONPATH="/app/src" 40 | 41 | # Install Node.js dependencies for JavaScript/JSX syntax checking 42 | # Install both globally and locally to ensure availability 43 | RUN npm install -g @babel/core @babel/cli @babel/preset-env @babel/preset-react && \ 44 | npm init -y && \ 45 | npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/preset-react 46 | 47 | # Create a basic .babelrc for JSX processing 48 | RUN echo '{"presets": ["@babel/preset-env", "@babel/preset-react"]}' > /app/.babelrc 49 | 50 | # Create a non-root user for security 51 | RUN useradd --create-home --shell /bin/bash mcp && \ 52 | chown -R mcp:mcp /app 53 | 54 | # Switch to non-root user 55 | USER mcp 56 | 57 | # Set the default command to run the MCP server directly from source 58 | CMD ["python", "-m", "text_editor.server"] 59 | 60 | # Health check (optional - checks if the module can be imported from source) 61 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 62 | CMD python -c "import text_editor.server; print('OK')" || exit 1 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Daniel Podrażka 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Editor MCP 2 | 3 | A Python-based text editor server built with FastMCP that provides powerful tools for file operations. This server enables reading, editing, and managing text files through a standardized API with a unique multi-step approach that significantly improves code editing accuracy and reliability for LLMs and AI assistants. 4 | 5 | [![Verified on MSeeP](https://mseep.ai/badge.svg)](https://mseep.ai/app/b23694aa-c58a-469d-ba3c-fb54eb4d0d88) 6 | 7 | ## Features 8 | 9 | - **File Selection**: Set a file to work with using absolute paths 10 | - **Read Operations**: 11 | - Read entire files with line numbers using `skim` 12 | - Read specific line ranges with prefixed line numbers using `read` 13 | - Find specific text within files using `find_line` 14 | - Find and extract function definitions in Python and JavaScript/JSX files using `find_function` 15 | - **Edit Operations**: 16 | - Two-step editing process with diff preview 17 | - Select and overwrite text with ID verification 18 | - Clean editing workflow with select → overwrite → confirm/cancel pattern 19 | - Syntax checking for Python (.py) and JavaScript/React (.js, .jsx) files 20 | - Create new files with content 21 | - **File Management**: 22 | - Create new files with proper initialization 23 | - Delete files from the filesystem 24 | - List directory contents with `listdir` 25 | - **Testing Support**: 26 | - Run Python tests with `run_tests` 27 | - Set Python paths for proper module resolution 28 | - **Safety Features**: 29 | - Content ID verification to prevent conflicts 30 | - Line count limits to prevent resource exhaustion 31 | - Syntax checking to maintain code integrity 32 | - Protected paths to restrict access to sensitive files 33 | 34 | ## Security Risks 35 | 36 | The editor-mcp includes powerful capabilities that come with certain security considerations: 37 | 38 | - **Jailbreak Risk**: The editor-mcp can potentially be jailbroken when reading a file that has harmful instructions embedded inside. Malicious content in files being edited could contain instructions that manipulate the AI assistant. 39 | - **Arbitrary Code Execution**: If running tests is enabled, there is a risk of arbitrary code execution through manipulated test files or malicious Python code. 40 | - **Data Exposure**: Access to file system operations could potentially expose sensitive information if proper path protections aren't configured. 41 | 42 | To mitigate these risks: 43 | 44 | 1. Use the `PROTECTED_PATHS` environment variable to restrict access to sensitive files and directories. 45 | 2. Disable test running capabilities in production environments unless absolutely necessary. 46 | 3. Carefully review files before opening them, especially if they come from untrusted sources. 47 | 4. Consider running the editor in a sandboxed environment with limited permissions. 48 | 49 | ## Key Advantages For LLMs 50 | 51 | This text editor's unique design solves critical problems that typically affect LLM code editing: 52 | 53 | - **Prevents Loss of Context** - Traditional approaches often lead to LLMs losing overview of the codebase after a few edits. This implementation maintains context through the multi-step process. 54 | 55 | - **Avoids Resource-Intensive Rewrites** - LLMs typically default to replacing entire files when confused, which is costly, slow, and inefficient. This editor enforces selective edits. 56 | 57 | - **Provides Visual Feedback** - The diff preview system allows the LLM to actually see and verify changes before committing them, dramatically reducing errors. 58 | 59 | - **Enforces Syntax Checking** - Automatic validation for Python and JavaScript/React ensures that broken code isn't committed. 60 | 61 | - **Improves Edit Reasoning** - The multi-step approach gives the LLM time to reason between steps, reducing haphazard token production. 62 | 63 | ## Resource Management 64 | 65 | The editor implements several safeguards to ensure system stability and prevent resource exhaustion: 66 | 67 | - **Maximum Edit Lines**: By default, the editor enforces a 50-line limit for any single edit operation 68 | ## Installation 69 | 70 | This MCP was developed and tested with Claude Desktop. You can download Claude Desktop on any platform. 71 | For Claude Desktop on Linux, you can use an unofficial installation script (uses the official file), recommended repository: 72 | https://github.com/emsi/claude-desktop/tree/main 73 | 74 | Once you have Claude Desktop installed, follow the instructions below to install this specific MCP: 75 | 76 | ### Easy Installation with UVX (Recommended) 77 | 78 | The easiest way to install the Editor MCP is using the provided installation script: 79 | 80 | ```bash 81 | # Clone the repository 82 | git clone https://github.com/danielpodrazka/editor-mcp.git 83 | cd editor-mcp 84 | 85 | # Run the installation script 86 | chmod +x install.sh 87 | ./install.sh 88 | ``` 89 | 90 | This script will: 91 | 1. Check if UVX is installed and install it if necessary 92 | 2. Install the Editor MCP in development mode 93 | 3. Make the `editor-mcp` command available in your PATH 94 | 95 | ### Manual Installation 96 | 97 | #### Using UVX 98 | 99 | ```bash 100 | # Install directly from GitHub 101 | uvx install git+https://github.com/danielpodrazka/mcp-text-editor.git 102 | 103 | # Or install from a local clone 104 | git clone https://github.com/danielpodrazka/mcp-text-editor.git 105 | cd mcp-text-editor 106 | uvx install -e . 107 | ``` 108 | 109 | #### Using Traditional pip 110 | 111 | ```bash 112 | pip install git+https://github.com/danielpodrazka/mcp-text-editor.git 113 | 114 | # Or from a local clone 115 | git clone https://github.com/danielpodrazka/mcp-text-editor.git 116 | cd mcp-text-editor 117 | pip install -e . 118 | ``` 119 | 120 | #### Using Requirements (Legacy) 121 | 122 | Install from the lock file: 123 | ```bash 124 | uv pip install -r uv.lock 125 | ``` 126 | 127 | ### Generating a locked requirements file: 128 | ```bash 129 | uv pip compile requirements.in -o uv.lock 130 | ``` 131 | 132 | ## Usage 133 | 134 | ### Starting the Server 135 | 136 | After installation, you can start the Editor MCP server using one of these methods: 137 | 138 | ```bash 139 | # Using the installed script 140 | editor-mcp 141 | 142 | # Or using the Python module 143 | python -m text_editor.server 144 | ``` 145 | 146 | ### MCP Configuration 147 | 148 | You can add the Editor MCP to your MCP configuration file: 149 | 150 | ```json 151 | { 152 | "mcpServers": { 153 | "text-editor": { 154 | "command": "editor-mcp", 155 | "env": { 156 | "MAX_SELECT_LINES": "100", 157 | "ENABLE_JS_SYNTAX_CHECK": "0", 158 | "FAIL_ON_PYTHON_SYNTAX_ERROR": "1", 159 | "FAIL_ON_JS_SYNTAX_ERROR": "0", 160 | "PROTECTED_PATHS": "*.env,.env*,config*.json,*secret*,/etc/passwd,/home/user/.ssh/id_rsa" 161 | } 162 | } 163 | } 164 | } 165 | ``` 166 | 167 | ### Environment Variable Configuration 168 | 169 | The Editor MCP supports several environment variables to customize its behavior: 170 | 171 | - **MAX_SELECT_LINES**: "100" - Maximum number of lines that can be edited in a single operation (default is 50) 172 | 173 | - **ENABLE_JS_SYNTAX_CHECK**: "0" - Enable/disable JavaScript and JSX syntax checking (default is "1" - enabled) 174 | 175 | - **FAIL_ON_PYTHON_SYNTAX_ERROR**: "1" - When enabled, Python syntax errors will automatically cancel the overwrite operation (default is enabled) 176 | 177 | - **FAIL_ON_JS_SYNTAX_ERROR**: "0" - When enabled, JavaScript/JSX syntax errors will automatically cancel the overwrite operation (default is disabled) 178 | 179 | - **PROTECTED_PATHS**: Comma-separated list of file patterns or paths that cannot be accessed, supporting wildcards (e.g., "*.env,.env*,/etc/passwd") 180 | 181 | ### Sample MCP Config When Building From Source 182 | 183 | ```json 184 | { 185 | "mcpServers": { 186 | "text-editor": { 187 | "command": "/home/daniel/pp/venvs/editor-mcp/bin/python", 188 | "args": ["/home/daniel/pp/editor-mcp/src/text_editor/server.py"], 189 | "env": { 190 | "MAX_SELECT_LINES": "100", 191 | "ENABLE_JS_SYNTAX_CHECK": "0", 192 | "FAIL_ON_PYTHON_SYNTAX_ERROR": "1", 193 | "FAIL_ON_JS_SYNTAX_ERROR": "0", 194 | "PROTECTED_PATHS": "*.env,.env*,config*.json,*secret*,/etc/passwd,/home/user/.ssh/id_rsa" 195 | } 196 | } 197 | } 198 | } 199 | ``` 200 | 201 | ## Available Tools 202 | The Editor MCP provides 13 powerful tools for file manipulation, editing, and testing: 203 | 204 | #### 1. `set_file` 205 | Sets the current file to work with. 206 | 207 | **Parameters**: 208 | - `filepath` (str): Absolute path to the file 209 | 210 | **Returns**: 211 | - Confirmation message with the file path 212 | 213 | #### 2. `skim` 214 | Reads full text from the current file. Each line is prefixed with its line number. 215 | 216 | **Returns**: 217 | - Dictionary containing lines with their line numbers, total number of lines, and the max edit lines setting 218 | 219 | **Example output**: 220 | ``` 221 | { 222 | "lines": [ 223 | [1, "def hello():"], 224 | [2, " print(\"Hello, world!\")"], 225 | [3, ""], 226 | [4, "hello()"] 227 | ], 228 | "total_lines": 4, 229 | "max_select_lines": 50 230 | } 231 | ``` 232 | 233 | #### 3. `read` 234 | Reads text from the current file from start line to end line. 235 | 236 | **Parameters**: 237 | - `start` (int): Start line number (1-based indexing) 238 | - `end` (int): End line number (1-based indexing) 239 | 240 | **Returns**: 241 | - Dictionary containing lines with their line numbers as keys, along with start and end line information 242 | 243 | **Example output**: 244 | ``` 245 | { 246 | "lines": [ 247 | [1, "def hello():"], 248 | [2, " print(\"Hello, world!\")"], 249 | [3, ""], 250 | [4, "hello()"] 251 | ], 252 | "start_line": 1, 253 | "end_line": 4 254 | } 255 | ``` 256 | 257 | #### 4. `select` 258 | Select a range of lines from the current file for subsequent overwrite operation. 259 | 260 | **Parameters**: 261 | - `start` (int): Start line number (1-based) 262 | - `end` (int): End line number (1-based) 263 | 264 | **Returns**: 265 | - Dictionary containing the selected lines, line range, and ID for verification 266 | 267 | **Note**: 268 | - This tool validates the selection against max_select_lines 269 | - The selection details are stored for use in the overwrite tool 270 | - This must be used before calling the overwrite tool 271 | 272 | #### 5. `overwrite` 273 | Prepare to overwrite a range of lines in the current file with new text. 274 | 275 | **Parameters**: 276 | - `new_lines` (list): List of new lines to overwrite the selected range 277 | 278 | **Returns**: 279 | - Diff preview showing the proposed changes 280 | 281 | **Note**: 282 | - This is the first step in a two-step process: 283 | 1. First call overwrite() to generate a diff preview 284 | 2. Then call confirm() to apply or cancel() to discard the pending changes 285 | - This tool allows replacing the previously selected lines with new content 286 | - The number of new lines can differ from the original selection 287 | - For Python files (.py extension), syntax checking is performed before writing 288 | - For JavaScript/React files (.js, .jsx extensions), syntax checking is optional and can be disabled via the `ENABLE_JS_SYNTAX_CHECK` environment variable 289 | 290 | #### 6. `confirm` 291 | Apply pending changes from the overwrite operation. 292 | 293 | **Returns**: 294 | - Operation result with status and message 295 | 296 | **Note**: 297 | - This is one of the two possible actions in the second step of the editing process 298 | - The selection is removed upon successful application of changes 299 | 300 | #### 7. `cancel` 301 | Discard pending changes from the overwrite operation. 302 | 303 | **Returns**: 304 | - Operation result with status and message 305 | 306 | **Note**: 307 | - This is one of the two possible actions in the second step of the editing process 308 | - The selection remains intact when changes are cancelled 309 | 310 | #### 8. `delete_file` 311 | Delete the currently set file. 312 | 313 | **Returns**: 314 | - Operation result with status and message 315 | 316 | #### 9. `new_file` 317 | Creates a new file and automatically sets it as the current file for subsequent operations. 318 | 319 | **Parameters**: 320 | - `filepath` (str): Path of the new file 321 | 322 | **Returns**: 323 | - Operation result with status, message, and selection info 324 | - The first line is automatically selected for editing 325 | 326 | **Behavior**: 327 | - Automatically creates parent directories if they don't exist 328 | - Sets the newly created file as the current working file 329 | - The first line is pre-selected, ready for immediate editing 330 | 331 | **Protected Files Note**: 332 | - Files matching certain patterns (like `*.env`) can be created normally 333 | - However, once you move to another file, these protected files cannot be reopened 334 | - This allows for a "write-once, protect-after" workflow for sensitive configuration files 335 | - Example: You can create `config.env`, populate it with example config, but cannot reopen it later 336 | 337 | **Note**: 338 | - This tool will fail if the current file exists and is not empty 339 | 340 | #### 10. `find_line` 341 | Find lines that match provided text in the current file. 342 | 343 | **Parameters**: 344 | - `search_text` (str): Text to search for in the file 345 | 346 | **Returns**: 347 | - Dictionary containing matching lines with their line numbers and total matches 348 | 349 | **Example output**: 350 | ``` 351 | { 352 | "status": "success", 353 | "matches": [ 354 | [2, " print(\"Hello, world!\")"] 355 | ], 356 | "total_matches": 1 357 | } 358 | ``` 359 | 360 | **Note**: 361 | - Returns an error if no file path is set 362 | - Searches for exact text matches within each line 363 | - The id can be used for subsequent edit operations 364 | 365 | #### 11. `find_function` 366 | Find a function or method definition in the current Python or JavaScript/JSX file. 367 | 368 | **Parameters**: 369 | - `function_name` (str): Name of the function or method to find 370 | 371 | **Returns**: 372 | - Dictionary containing the function lines with their line numbers, start_line, and end_line 373 | 374 | **Example output**: 375 | ``` 376 | { 377 | "status": "success", 378 | "lines": [ 379 | [10, "def hello():"], 380 | [11, " print(\"Hello, world!\")"], 381 | [12, " return True"] 382 | ], 383 | "start_line": 10, 384 | "end_line": 12 385 | } 386 | ``` 387 | 388 | **Note**: 389 | - For Python files, this tool uses Python's AST and tokenize modules to accurately identify function boundaries including decorators and docstrings 390 | - For JavaScript/JSX files, this tool uses a combination of approaches: 391 | - Primary method: Babel AST parsing when available (requires Node.js and Babel packages) 392 | - Fallback method: Regex pattern matching for function declarations when Babel is unavailable 393 | - Supports various JavaScript function types including standard functions, async functions, arrow functions, and React hooks 394 | - Returns an error if no file path is set or if the function is not found 395 | 396 | #### 12. `listdir` 397 | Lists the contents of a directory. 398 | 399 | **Parameters**: 400 | - `dirpath` (str): Path to the directory to list 401 | 402 | **Returns**: 403 | - Dictionary containing list of filenames and the path queried 404 | 405 | #### 13. `run_tests` and `set_python_path` 406 | Tools for running Python tests with pytest and configuring the Python environment. 407 | - Set to "0", "false", or "no" to disable JavaScript syntax checking 408 | - Useful if you don't have Babel and related dependencies installed 409 | - `FAIL_ON_PYTHON_SYNTAX_ERROR`: Controls whether Python syntax errors automatically cancel the overwrite operation (default: 1) 410 | - When enabled, syntax errors in Python files will cause the overwrite action to be automatically cancelled 411 | - The lines will remain selected so you can fix the error and try again 412 | - `FAIL_ON_JS_SYNTAX_ERROR`: Controls whether JavaScript/JSX syntax errors automatically cancel the overwrite operation (default: 0) 413 | - When enabled, syntax errors in JavaScript/JSX files will cause the overwrite action to be automatically cancelled 414 | - The lines will remain selected so you can fix the error and try again 415 | - `DUCKDB_USAGE_STATS`: Controls whether usage statistics are collected in a DuckDB database (default: 0) 416 | - Set to "1", "true", or "yes" to enable collection of tool usage statistics 417 | - When enabled, records information about each tool call including timestamps and arguments 418 | - `STATS_DB_PATH`: Path where the DuckDB database for statistics will be stored (default: "text_editor_stats.duckdb") 419 | - Only used when `DUCKDB_USAGE_STATS` is enabled 420 | - `PROTECTED_PATHS`: Comma-separated list of file patterns or absolute paths that will be denied access 421 | - Example: `*.env,.env*,config*.json,*secret*,/etc/passwd,/home/user/credentials.txt` 422 | - Supports both exact file paths and flexible glob patterns with wildcards in any position: 423 | - `*.env` - matches files ending with .env, like `.env`, `dev.env`, `prod.env` 424 | - `.env*` - matches files starting with .env, like `.env`, `.env.local`, `.env.production` 425 | - `*secret*` - matches any file containing 'secret' in the name 426 | - Provides protection against accidentally exposing sensitive configuration files and credentials 427 | - The lines will remain selected so you can fix the error and try again 428 | 429 | ## Development 430 | 431 | ### Prerequisites 432 | 433 | The editor-mcp requires: 434 | - Python 3.7+ 435 | - FastMCP package 436 | - black (for Python code formatting checks) 437 | - Babel (for JavaScript/JSX syntax checks if working with those files) 438 | 439 | Install development dependencies: 440 | 441 | ```bash 442 | # Using pip 443 | pip install pytest pytest-asyncio pytest-cov 444 | 445 | # Using uv 446 | uv pip install pytest pytest-asyncio pytest-cov 447 | ``` 448 | 449 | For JavaScript/JSX syntax validation, you need Node.js and Babel. The text editor uses `npx babel` to check JS/JSX syntax when editing these file types: 450 | 451 | ```bash 452 | # Required for JavaScript/JSX syntax checking 453 | npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/preset-react 454 | # You can also install these globally if you prefer 455 | # npm install -g @babel/core @babel/cli @babel/preset-env @babel/preset-react 456 | ``` 457 | 458 | The editor requires: 459 | - `@babel/core` and `@babel/cli` - Core Babel packages for syntax checking 460 | - `@babel/preset-env` - For standard JavaScript (.js) files 461 | - `@babel/preset-react` - For React JSX (.jsx) files 462 | 463 | ### Running Tests 464 | 465 | ```bash 466 | # Run tests 467 | pytest -v 468 | 469 | # Run tests with coverage 470 | pytest -v --cov=text_editor 471 | ``` 472 | 473 | ### Test Structure 474 | 475 | The test suite covers: 476 | 477 | 1. **set_file tool** 478 | - Setting valid files 479 | - Setting non-existent files 480 | 481 | 2. **read tool** 482 | - File state validation 483 | - Reading entire files 484 | - Reading specific line ranges 485 | - Edge cases like empty files 486 | - Invalid range handling 487 | 488 | 3. **select tool** 489 | - Line range validation 490 | - Selection validation against max_select_lines 491 | - Selection storage for subsequent operations 492 | 493 | 4. **overwrite tool** 494 | - Verification of selected content using ID 495 | - Content replacement validation 496 | - Syntax checking for Python and JavaScript/React files 497 | - Generation of diff preview for changes 498 | 499 | 5. **confirm and cancel tools** 500 | - Applying or canceling pending changes 501 | - Two-step verification process 502 | 503 | 6. **delete_file tool** 504 | - File deletion validation 505 | 506 | 7. **new_file tool** 507 | - File creation validation 508 | - Handling existing files 509 | 510 | 8. **find_line tool** 511 | - Finding text matches in files 512 | - Handling specific search terms 513 | - Error handling for non-existent files 514 | - Handling cases with no matches 515 | - Handling existing files 516 | 517 | ## How it Works 518 | 519 | ### The Multi-Step Editing Approach 520 | 521 | Unlike traditional code editing approaches where LLMs simply search for lines to edit and make replacements (often leading to confusion after multiple edits), this editor implements a structured multi-step workflow that dramatically improves editing accuracy: 522 | 523 | 1. **set_file** - First, the LLM sets which file it wants to edit 524 | 2. **skim** - The LLM reads the entire file to gain a complete overview 525 | 3. **read** - The LLM examines specific sections relevant to the task, with lines shown alongside numbers for better context 526 | 4. **select** - When ready to edit, the LLM selects specific lines (limited to a configurable number, default 50) 527 | 5. **overwrite** - The LLM proposes replacement content, resulting in a git diff-style preview that shows exactly what will change 528 | 6. **confirm/cancel** - After reviewing the preview, the LLM can either apply or discard the changes 529 | 530 | This structured workflow forces the LLM to reason carefully about each edit and prevents common errors like accidentally overwriting entire files. By seeing previews of changes before committing them, the LLM can verify its edits are correct. 531 | 532 | ### ID Verification System 533 | 534 | The server uses FastMCP to expose text editing capabilities through a well-defined API. The ID verification system ensures data integrity by verifying that the content hasn't changed between reading and modifying operations. 535 | 536 | The ID mechanism uses SHA-256 to generate a unique identifier of the file content or selected line ranges. For line-specific operations, the ID includes a prefix indicating the line range (e.g., "L10-15-[hash]"). This helps ensure that edits are being applied to the expected content. 537 | 538 | ## Implementation Details 539 | 540 | The main `TextEditorServer` class: 541 | 542 | 1. Initializes with a FastMCP instance named "text-editor" 543 | 2. Sets a configurable `max_select_lines` limit (default: 50) from environment variables 544 | 3. Maintains the current file path as state 545 | 4. Registers thirteen primary tools through FastMCP: 546 | - `set_file`: Validates and sets the current file path 547 | - `skim`: Reads the entire content of a file, returning a dictionary of line numbers to line text 548 | - `read`: Reads lines from specified line range, returning a structured dictionary of line content 549 | - `select`: Selects lines for subsequent overwrite operation 550 | - `overwrite`: Takes a list of new lines and prepares diff preview for changing content 551 | - `confirm`: Applies pending changes from the overwrite operation 552 | - `cancel`: Discards pending changes from the overwrite operation 553 | - `delete_file`: Deletes the current file 554 | - `new_file`: Creates a new file 555 | - `find_line`: Finds lines containing specific text 556 | - `find_function`: Finds function or method definitions in Python and JavaScript/JSX files 557 | - `listdir`: Lists contents of a directory 558 | - `run_tests` and `set_python_path`: Tools for running Python tests 559 | 560 | The server runs using FastMCP's stdio transport by default, making it easy to integrate with various clients. 561 | 562 | ## System Prompt for Best Results 563 | 564 | For optimal results with AI assistants, it's recommended to use the system prompt (see [system_prompt.md](system_prompt.md)) that helps guide the AI in making manageable, safe edits. 565 | 566 | This system prompt helps the AI assistant: 567 | 568 | 1. **Make incremental changes** - Breaking down edits into smaller parts 569 | 2. **Maintain code integrity** - Making changes that keep the code functional 570 | 3. **Work within resource limits** - Avoiding operations that could overwhelm the system 571 | 4. **Follow a verification workflow** - Doing final checks for errors after edits 572 | 573 | By incorporating this system prompt when working with AI assistants, you'll get more reliable editing behavior and avoid common pitfalls in automated code editing. 574 | 575 | ![example.png](example.png) 576 | 577 | ## Usage Statistics 578 | 579 | The text editor MCP can collect usage statistics when enabled, providing insights into how the editing tools are being used: 580 | 581 | - **Data Collection**: Statistics are collected in a DuckDB database when `DUCKDB_USAGE_STATS` is enabled 582 | - **Tracked Information**: Records tool name, arguments, timestamp, current file path, tool response, and request/client IDs 583 | - **Storage Location**: Data is stored in a DuckDB file specified by `STATS_DB_PATH` 584 | - **Privacy**: Everything is stored locally on your machine 585 | 586 | The collected statistics can help understand usage patterns, identify common workflows, and optimize the editor for most frequent operations. 587 | 588 | You can query the database using standard SQL via any DuckDB client to analyze usage patterns. 589 | ## Troubleshooting 590 | 591 | If you encounter issues: 592 | 593 | 1. Check file permissions 594 | 2. Verify that the file paths are absolute 595 | 3. Ensure the environment is using Python 3.7+ 596 | 597 | 598 | ## Inspiration 599 | 600 | Inspired by a similar project: https://github.com/tumf/mcp-text-editor, which at first I forked, however I decided to rewrite the whole codebase from scratch so only the general idea stayed the same. 601 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Easy installation script for MCP Text Editor using UVX 3 | 4 | # Check if UVX is installed 5 | if ! command -v uvx &> /dev/null; then 6 | echo "UVX not found. Installing UVX..." 7 | curl -sS https://raw.githubusercontent.com/astral-sh/uv/main/scripts/install.sh | sh 8 | 9 | # Add UV to PATH for this session 10 | export PATH="$HOME/.cargo/bin:$PATH" 11 | fi 12 | 13 | # Install the package 14 | echo "Installing MCP Text Editor..." 15 | uvx install -e . 16 | 17 | echo "MCP Text Editor installed successfully!" 18 | echo "You can now run it using the 'editor-mcp' command" -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "text-editor" 7 | version = "0.1.0" 8 | description = "A Python-based text editor server built with FastMCP" 9 | readme = "README.md" 10 | authors = [ 11 | {name = "Daniel Podrażka", email = "build@daniep.com"}, 12 | ] 13 | license = {file = "LICENSE"} 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | ] 19 | requires-python = ">=3.11" 20 | dependencies = [ 21 | "black", 22 | "fastmcp>=2.10.1", 23 | "duckdb" 24 | ] 25 | 26 | [project.optional-dependencies] 27 | dev = [ 28 | "pytest", 29 | "pytest-asyncio", 30 | "pytest-cov", 31 | ] 32 | 33 | [project.scripts] 34 | text-editor = "text_editor.server:main" 35 | 36 | [project.urls] 37 | "Homepage" = "https://github.com/danielpodrazka/text-editor" 38 | "Bug Tracker" = "https://github.com/danielpodrazka/text-editor/issues" -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # This file is included for backward compatibility 3 | 4 | from setuptools import setup 5 | 6 | if __name__ == "__main__": 7 | setup() 8 | -------------------------------------------------------------------------------- /src/text_editor/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MCP Text Editor Package 3 | 4 | A Python-based text editor server built with FastMCP that provides tools 5 | for file operations. This server enables reading, editing, and managing 6 | text files through a standardized API. 7 | """ 8 | 9 | __version__ = "0.1.0" 10 | -------------------------------------------------------------------------------- /src/text_editor/server.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import os 4 | import re 5 | import subprocess 6 | import tempfile 7 | import ast 8 | import tokenize 9 | import fnmatch 10 | import io 11 | import datetime 12 | import json 13 | import inspect 14 | import functools 15 | from typing import Optional, Dict, Any, Union, Literal 16 | import argparse 17 | import black 18 | from black.report import NothingChanged 19 | from fastmcp import FastMCP 20 | import duckdb 21 | 22 | 23 | logging.basicConfig( 24 | level=logging.INFO, 25 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 26 | datefmt="%Y-%m-%d %H:%M:%S", 27 | ) 28 | 29 | logger = logging.getLogger("text_editor") 30 | 31 | 32 | def calculate_id(text: str, start: int = None, end: int = None) -> str: 33 | """ 34 | Calculate a unique ID for content verification based on the text content. 35 | 36 | The ID is formed by combining a line prefix (if line numbers are provided) 37 | with a truncated SHA-256 id of the content. This allows quick verification 38 | that content hasn't changed between operations. 39 | 40 | Args: 41 | text (str): Content to generate ID for 42 | start (Optional[int]): Starting line number for the content 43 | end (Optional[int]): Ending line number for the content 44 | Returns: 45 | str: ID string in format: [LinePrefix]-[Truncatedid] 46 | Example: "L10-15-a7" for content spanning lines 10-15 47 | Example: "L5-b3" for content on line 5 only 48 | """ 49 | prefix = "" 50 | if start and end: 51 | prefix = f"L{start}-{end}-" 52 | if start == end: 53 | prefix = f"L{start}-" 54 | 55 | return f"{prefix}{hashlib.sha256(text.encode()).hexdigest()[:2]}" 56 | 57 | 58 | def generate_diff_preview( 59 | original_lines: list, modified_lines: list, start: int, end: int 60 | ) -> dict: 61 | """ 62 | Generate a diff preview comparing original and modified content. 63 | 64 | Args: 65 | original_lines (list): List of original file lines 66 | modified_lines (list): List of modified file lines 67 | start (int): Start line number of the edit (1-based) 68 | end (int): End line number of the edit (1-based) 69 | 70 | Returns: 71 | dict: A dictionary with keys prefixed with + or - to indicate additions/deletions 72 | Format: [("-1", "removed line"), ("+1", "added line")] 73 | """ 74 | diffs = [] 75 | # Add some context lines before the change 76 | context_start = max(0, start - 1 - 3) # 3 lines of context before 77 | for i in range(context_start, start - 1): 78 | diffs.append((i + 1, original_lines[i].rstrip())) 79 | # Show removed lines 80 | for i in range(start - 1, end): 81 | diffs.append((f"-{i+1}", original_lines[i].rstrip())) 82 | 83 | # Show added lines 84 | new_content = "".join( 85 | modified_lines[ 86 | start - 1 : start 87 | - 1 88 | + len(modified_lines) 89 | - len(original_lines) 90 | + (end - (start - 1)) 91 | ] 92 | ) 93 | new_lines = new_content.splitlines() 94 | for i, line in enumerate(new_lines): 95 | diffs.append((f"+{start+i}", line)) 96 | context_end = min(len(original_lines), end + 3) # 3 lines of context after 97 | for i in range(end, context_end): 98 | diffs.append((i + 1, original_lines[i].rstrip())) 99 | return { 100 | "diff_lines": diffs, 101 | } 102 | 103 | 104 | def create_logging_tool_decorator(original_decorator, log_callback): 105 | """ 106 | Create a wrapper around the FastMCP tool decorator that logs tool usage. 107 | 108 | Args: 109 | original_decorator: The original FastMCP tool decorator 110 | log_callback: A callback function that will be called with (tool_name, args_dict, response) 111 | 112 | Returns: 113 | A wrapped decorator function that logs tool usage 114 | """ 115 | 116 | def tool_decorator_with_logging(*args, **kwargs): 117 | # Get the original decorator with args 118 | wrapped_decorator = original_decorator(*args, **kwargs) 119 | 120 | def wrapper(func): 121 | # Create our logging wrapper 122 | @functools.wraps(func) # Preserve func's metadata 123 | async def logged_func(*func_args, **func_kwargs): 124 | tool_name = func.__name__ 125 | # Convert args to dict for logging 126 | args_dict = {} 127 | if func_args and len(func_args) > 0: 128 | # Get parameter names from function signature 129 | sig = inspect.signature(func) 130 | param_names = list(sig.parameters.keys()) 131 | # Skip self parameter if it exists 132 | if param_names and param_names[0] == "self": 133 | param_names = param_names[1:] 134 | for i, arg in enumerate(func_args): 135 | if i < len(param_names): 136 | args_dict[param_names[i]] = ( 137 | str(arg) if isinstance(arg, (bytes, bytearray)) else arg 138 | ) 139 | args_dict.update(func_kwargs) 140 | 141 | # Call the original function 142 | response = await func(*func_args, **func_kwargs) 143 | 144 | # Log the tool usage with the response 145 | log_callback(tool_name, args_dict, response) 146 | 147 | # Return the response 148 | return response 149 | 150 | # Apply the original decorator to our logged function 151 | wrapped_func = wrapped_decorator(logged_func) 152 | return wrapped_func 153 | 154 | return wrapper 155 | 156 | return tool_decorator_with_logging 157 | 158 | 159 | class TextEditorServer: 160 | """ 161 | A server implementation for a text editor application using FastMCP. 162 | 163 | This class provides a comprehensive set of tools for manipulating text files in a 164 | controlled and safe manner. It implements a structured editing workflow with content 165 | verification to prevent conflicts during editing operations. 166 | 167 | Primary features: 168 | - File management: Setting active file, creating new files, and deleting files 169 | - Content access: Reading full file content or specific line ranges 170 | - Text search: Finding lines matching specific text patterns 171 | - Safe editing: Two-step edit process with diff preview and confirmation 172 | - Syntax validation: Automatic syntax checking for Python and JavaScript files 173 | - Protected files: Prevent access to sensitive files via pattern matching 174 | 175 | The server uses content hashing to generate unique IDs that ensure file content 176 | integrity during editing operations. All tools are registered with FastMCP for 177 | remote procedure calling. 178 | 179 | Edit workflow: 180 | 1. Select content range with the select() tool to identify lines for editing 181 | 2. Propose changes with overwrite() to generate a diff preview 182 | 3. Use confirm() to apply or cancel() to discard the pending modifications 183 | 184 | Attributes: 185 | mcp (FastMCP): The MCP server instance for handling tool registrations 186 | max_select_lines (int): Maximum number of lines that can be edited with ID verification 187 | enable_js_syntax_check (bool): Whether JavaScript syntax checking is enabled 188 | protected_paths (list): List of file patterns and paths that are restricted from access 189 | current_file_path (str, optional): Path to the currently active file 190 | selected_start (int, optional): Start line of the current selection 191 | selected_end (int, optional): End line of the current selection 192 | selected_id (str, optional): ID of the current selection for verification 193 | pending_modified_lines (list, optional): Pending modified lines for preview before committing 194 | pending_diff (dict, optional): Diff preview of pending changes 195 | """ 196 | 197 | def __init__(self): 198 | # Initialize MCP server 199 | self.mcp = FastMCP("text-editor") 200 | 201 | # Initialize DuckDB for usage statistics if enabled 202 | self.usage_stats_enabled = os.getenv("DUCKDB_USAGE_STATS", "0").lower() in [ 203 | "1", 204 | "true", 205 | "yes", 206 | ] 207 | if self.usage_stats_enabled: 208 | self.stats_db_path = os.getenv("STATS_DB_PATH", "text_editor_stats.duckdb") 209 | self._init_stats_db() 210 | # Replace the original tool decorator with our wrapper 211 | # Making sure we preserve all the original functionality 212 | original_tool = self.mcp.tool 213 | logger.debug( 214 | { 215 | "message": f"Setting up logging tool decorator using {self.stats_db_path}" 216 | } 217 | ) 218 | self.mcp.tool = create_logging_tool_decorator( 219 | original_tool, self._log_tool_usage 220 | ) 221 | logger.debug({"msg": "Logging tool decorator set up complete"}) 222 | self.max_select_lines = int(os.getenv("MAX_SELECT_LINES", "50")) 223 | self.enable_js_syntax_check = os.getenv( 224 | "ENABLE_JS_SYNTAX_CHECK", "1" 225 | ).lower() in ["1", "true", "yes"] 226 | self.fail_on_python_syntax_error = os.getenv( 227 | "FAIL_ON_PYTHON_SYNTAX_ERROR", "1" 228 | ).lower() in ["1", "true", "yes"] 229 | self.fail_on_js_syntax_error = os.getenv( 230 | "FAIL_ON_JS_SYNTAX_ERROR", "0" 231 | ).lower() in ["1", "true", "yes"] 232 | self.protected_paths = ( 233 | os.getenv("PROTECTED_PATHS", "").split(",") 234 | if os.getenv("PROTECTED_PATHS") 235 | else [] 236 | ) 237 | self.current_file_path = None 238 | self.selected_start = None 239 | self.selected_end = None 240 | self.selected_id = None 241 | self.pending_modified_lines = None 242 | self.pending_diff = None 243 | self.python_venv = os.getenv("PYTHON_VENV") 244 | 245 | self.register_tools() 246 | 247 | def _init_stats_db(self): 248 | """Initialize the DuckDB database for storing tool usage statistics.""" 249 | logger.debug({"msg": f"Initializing stats database at {self.stats_db_path}"}) 250 | try: 251 | # Connect to DuckDB and create the table if it doesn't exist 252 | with duckdb.connect(self.stats_db_path) as conn: 253 | logger.debug({"msg": "Connected to DuckDB for initialization"}) 254 | conn.execute(""" 255 | CREATE TABLE IF NOT EXISTS tool_usage ( 256 | tool_name VARCHAR, 257 | args JSON, 258 | response JSON, 259 | timestamp TIMESTAMP, 260 | current_file VARCHAR, 261 | request_id VARCHAR, 262 | client_id VARCHAR 263 | ) 264 | """) 265 | conn.commit() 266 | logger.debug({"msg": "Database tables initialized successfully"}) 267 | except Exception as e: 268 | logger.debug({"msg": f"Error initializing stats database: {str(e)}"}) 269 | 270 | def _log_tool_usage(self, tool_name: str, args: dict, response=None): 271 | """ 272 | Log tool usage to the DuckDB database if stats are enabled. 273 | This function is called by the decorator wrapper for each tool invocation. 274 | 275 | Args: 276 | tool_name (str): Name of the tool being used 277 | args (dict): Arguments passed to the tool 278 | response (dict, optional): Response returned by the tool 279 | """ 280 | # Skip if stats are disabled 281 | if not hasattr(self, "usage_stats_enabled") or not self.usage_stats_enabled: 282 | return 283 | 284 | logger.debug({"message": f"Logging tool usage: {tool_name}"}) 285 | client_id = None 286 | try: 287 | # Get request ID if available 288 | request_id = None 289 | if hasattr(self.mcp, "_mcp_server") and hasattr( 290 | self.mcp._mcp_server, "request_context" 291 | ): 292 | request_id = getattr( 293 | self.mcp._mcp_server.request_context, "request_id", None 294 | ) 295 | 296 | if hasattr(self.mcp, "_mcp_server") and hasattr( 297 | self.mcp._mcp_server, "request_context" 298 | ): 299 | # Access client_id from meta if available 300 | if ( 301 | hasattr(self.mcp._mcp_server.request_context, "meta") 302 | and self.mcp._mcp_server.request_context.meta 303 | ): 304 | client_id = getattr( 305 | self.mcp._mcp_server.request_context.meta, "client_id", None 306 | ) 307 | 308 | # Safely convert args to serializable format 309 | safe_args = {} 310 | for key, value in args.items(): 311 | # Skip large objects and convert non-serializable objects to strings 312 | if ( 313 | hasattr(value, "__len__") 314 | and not isinstance(value, (str, dict, list, tuple)) 315 | and len(value) > 1000 316 | ): 317 | safe_args[key] = f"<{type(value).__name__} of length {len(value)}>" 318 | else: 319 | try: 320 | # Test if value is JSON serializable 321 | json.dumps({key: value}) 322 | safe_args[key] = value 323 | except (TypeError, OverflowError): 324 | # If not serializable, convert to string representation 325 | safe_args[key] = f"<{type(value).__name__}>" 326 | 327 | # Format arguments as JSON 328 | args_json = json.dumps(safe_args) 329 | 330 | # Process response - since we're using JSON RPC, responses should already be serializable 331 | response_json = None 332 | if response is not None: 333 | try: 334 | response_json = json.dumps(response) 335 | except (TypeError, OverflowError): 336 | # Handle edge cases where something non-serializable might have been returned 337 | logger.debug( 338 | { 339 | "message": f"Non-serializable response received from {tool_name}, converting to string representation" 340 | } 341 | ) 342 | if isinstance(response, dict): 343 | # For dictionaries, process each value separately 344 | safe_response = {} 345 | for key, value in response.items(): 346 | try: 347 | json.dumps({key: value}) 348 | safe_response[key] = value 349 | except (TypeError, OverflowError): 350 | safe_response[key] = f"<{type(value).__name__}>" 351 | response_json = json.dumps(safe_response) 352 | else: 353 | # For non-dict types, store a basic representation 354 | response_json = json.dumps({"result": str(response)}) 355 | 356 | logger.debug( 357 | {"msg": f"Attempting to connect to DuckDB at {self.stats_db_path}"} 358 | ) 359 | try: 360 | with duckdb.connect(self.stats_db_path) as conn: 361 | logger.debug({"msg": f"Connected to DuckDB successfully"}) 362 | conn.execute( 363 | """ 364 | INSERT INTO tool_usage (tool_name, args, response, timestamp, current_file, request_id, client_id) 365 | VALUES (?, ?, ?, ?, ?, ?, ?) 366 | """, 367 | ( 368 | tool_name, 369 | args_json, 370 | response_json, 371 | datetime.datetime.now(), 372 | self.current_file_path, 373 | request_id, 374 | client_id, 375 | ), 376 | ) 377 | conn.commit() 378 | logger.debug({"msg": f"Insert completed successfully"}) 379 | except Exception as e: 380 | logger.debug({"msg": f"DuckDB error: {str(e)}"}) 381 | except Exception as e: 382 | logger.debug({"msg": f"Error logging tool usage: {str(e)}"}) 383 | 384 | def register_tools(self): 385 | @self.mcp.tool() 386 | async def set_file(filepath: str) -> str: 387 | """ 388 | Set the current file to work with. 389 | 390 | This is always the first step in the workflow. You must set a file 391 | before you can use other tools like read, select etc. 392 | """ 393 | 394 | if not os.path.isfile(filepath): 395 | return f"Error: File not found at '{filepath}'" 396 | 397 | # Check if the file path matches any of the protected paths 398 | for pattern in self.protected_paths: 399 | pattern = pattern.strip() 400 | if not pattern: 401 | continue 402 | # Check for absolute path match 403 | if filepath == pattern: 404 | return f"Error: Access to '{filepath}' is denied due to PROTECTED_PATHS configuration" 405 | # Check for glob pattern match (e.g., *.env, .env*, etc.) 406 | if "*" in pattern: 407 | # First try matching the full path 408 | if fnmatch.fnmatch(filepath, pattern): 409 | return f"Error: Access to '{filepath}' is denied due to PROTECTED_PATHS configuration (matches pattern '{pattern}')" 410 | 411 | # Then try matching just the basename 412 | basename = os.path.basename(filepath) 413 | if fnmatch.fnmatch(basename, pattern): 414 | return f"Error: Access to '{filepath}' is denied due to PROTECTED_PATHS configuration (matches pattern '{pattern}')" 415 | 416 | self.current_file_path = filepath 417 | return f"File set to: '{filepath}'" 418 | 419 | @self.mcp.tool() 420 | async def skim() -> Dict[str, Any]: 421 | """ 422 | Read text from the current file, truncated to the first `SKIM_MAX_LINES` lines. 423 | 424 | Returns: 425 | dict: lines, total_lines, max_select_lines 426 | """ 427 | if self.current_file_path is None: 428 | return {"error": "No file path is set. Use set_file first."} 429 | with open(self.current_file_path, "r", encoding="utf-8") as file: 430 | lines = file.readlines() 431 | 432 | formatted_lines = [] 433 | max_lines_to_show = int(os.getenv("SKIM_MAX_LINES", "500")) 434 | lines_to_process = lines[:max_lines_to_show] 435 | 436 | for i, line in enumerate(lines_to_process, 1): 437 | formatted_lines.append((i, line.rstrip())) 438 | 439 | result = { 440 | "lines": formatted_lines, 441 | "total_lines": len(lines), 442 | "max_select_lines": self.max_select_lines, 443 | } 444 | 445 | # Add hint if file was truncated 446 | if len(lines) > max_lines_to_show: 447 | result["truncated"] = True 448 | result["hint"] = ( 449 | f"File has {len(lines)} total lines. Only showing first {max_lines_to_show} lines. Use `read` to view specific line ranges or `find_line` to search for content in the remaining lines." 450 | ) 451 | 452 | return result 453 | 454 | @self.mcp.tool() 455 | async def read(start: int, end: int) -> Dict[str, Any]: 456 | """ 457 | Read lines from the current file from start line to end line, returning them in a dictionary 458 | like {"lines":[[1,"text on first line"],[2,"text on second line"]]}. This makes it easier to find the precise lines to select for editing. 459 | 460 | Args: 461 | start (int, optional): Start line number 462 | end (int, optional): End line number 463 | 464 | Returns: 465 | dict: lines, start_line, end_line 466 | """ 467 | result = {} 468 | 469 | if self.current_file_path is None: 470 | return {"error": "No file path is set. Use set_file first."} 471 | 472 | try: 473 | with open(self.current_file_path, "r", encoding="utf-8") as file: 474 | lines = file.readlines() 475 | 476 | if start < 1: 477 | return {"error": "start must be at least 1"} 478 | if end > len(lines): 479 | end = len(lines) 480 | if start > end: 481 | return { 482 | "error": f"{start=} cannot be greater than {end=}. {len(lines)=}" 483 | } 484 | 485 | selected_lines = lines[start - 1 : end] 486 | 487 | formatted_lines = [] 488 | for i, line in enumerate(selected_lines, start): 489 | formatted_lines.append((i, line.rstrip())) 490 | 491 | result["lines"] = formatted_lines 492 | result["start_line"] = start 493 | result["end_line"] = end 494 | 495 | return result 496 | 497 | except Exception as e: 498 | return {"error": f"Error reading file: {str(e)}"} 499 | 500 | @self.mcp.tool() 501 | async def select( 502 | start: int, 503 | end: int, 504 | ) -> Dict[str, Any]: 505 | """ 506 | Select lines from for subsequent overwrite operation. 507 | 508 | Args: 509 | start (int): Start line number (1-based) 510 | end (int): End line number (1-based) 511 | 512 | Returns: 513 | dict: status, lines, start, end, id, line_count, message 514 | """ 515 | if self.current_file_path is None: 516 | return {"error": "No file path is set. Use set_file first."} 517 | 518 | try: 519 | with open(self.current_file_path, "r", encoding="utf-8") as file: 520 | lines = file.readlines() 521 | 522 | if start < 1: 523 | return {"error": "start must be at least 1."} 524 | 525 | if end > len(lines): 526 | end = len(lines) 527 | 528 | if start > end: 529 | return {"error": "start cannot be greater than end."} 530 | 531 | if end - start + 1 > self.max_select_lines: 532 | return { 533 | "error": f"Cannot select more than {self.max_select_lines} lines at once (attempted {end - start + 1} lines)." 534 | } 535 | 536 | selected_lines = lines[start - 1 : end] 537 | text = "".join(selected_lines) 538 | 539 | current_id = calculate_id(text, start, end) 540 | 541 | self.selected_start = start 542 | self.selected_end = end 543 | self.selected_id = current_id 544 | 545 | # Convert selected lines to a list without line numbers 546 | lines_content = [line.rstrip() for line in selected_lines] 547 | 548 | result = { 549 | "status": "success", 550 | "lines": lines_content, 551 | "start": start, 552 | "end": end, 553 | "id": current_id, 554 | "line_count": len(selected_lines), 555 | "message": f"Selected lines {start} to {end} for editing.", 556 | } 557 | 558 | return result 559 | 560 | except Exception as e: 561 | return {"error": f"Error selecting lines: {str(e)}"} 562 | 563 | @self.mcp.tool() 564 | async def overwrite( 565 | new_lines: dict, 566 | ) -> Dict[str, Any]: 567 | """ 568 | Overwrite the selected lines with new text. Amount of new lines can differ from the original selection 569 | 570 | Args: 571 | new_lines (dict): Example: {"lines":["line one", "second line"]} 572 | 573 | Returns: 574 | dict: Diff preview showing the proposed changes, and any syntax errors for JS or Python 575 | 576 | """ 577 | new_lines = new_lines.get("lines") 578 | if self.current_file_path is None: 579 | return {"error": "No file path is set. Use set_file first."} 580 | 581 | if ( 582 | self.selected_start is None 583 | or self.selected_end is None 584 | or self.selected_id is None 585 | ): 586 | return {"error": "No selection has been made. Use select tool first."} 587 | 588 | try: 589 | with open(self.current_file_path, "r", encoding="utf-8") as file: 590 | lines = file.readlines() 591 | except Exception as e: 592 | return {"error": f"Error reading file: {str(e)}"} 593 | 594 | start = self.selected_start 595 | end = self.selected_end 596 | id = self.selected_id 597 | 598 | current_content = "".join(lines[start - 1 : end]) 599 | 600 | computed_id = calculate_id(current_content, start, end) 601 | 602 | if computed_id != id: 603 | return { 604 | "error": "id verification failed. The content may have been modified since you last read it." 605 | } 606 | 607 | processed_new_lines = [] 608 | for line in new_lines: 609 | if not line.endswith("\n"): 610 | processed_new_lines.append(line + "\n") 611 | else: 612 | processed_new_lines.append(line) 613 | 614 | if ( 615 | processed_new_lines 616 | and end < len(lines) 617 | and not processed_new_lines[-1].endswith("\n") 618 | ): 619 | processed_new_lines[-1] += "\n" 620 | 621 | before = lines[: start - 1] 622 | after = lines[end:] 623 | modified_lines = before + processed_new_lines + after 624 | diff_result = generate_diff_preview(lines, modified_lines, start, end) 625 | error = None 626 | if self.current_file_path.endswith(".py"): 627 | full_content = "".join(modified_lines) 628 | try: 629 | black.format_file_contents( 630 | full_content, 631 | fast=True, 632 | mode=black.Mode(), 633 | ) 634 | except black.InvalidInput as e: 635 | error = { 636 | "error": f"Python syntax error: {str(e)}", 637 | "diff_lines": diff_result, 638 | "auto_cancel": self.fail_on_python_syntax_error, 639 | } 640 | except Exception as e: 641 | if not isinstance(e, NothingChanged): 642 | error = { 643 | "error": f"Black check raised {type(e)}: {str(e)}", 644 | "diff_lines": diff_result, 645 | } 646 | 647 | elif self.enable_js_syntax_check and self.current_file_path.endswith( 648 | (".jsx", ".js") 649 | ): 650 | with tempfile.NamedTemporaryFile( 651 | mode="w", suffix=".jsx", delete=False 652 | ) as temp: 653 | temp_path = temp.name 654 | temp.writelines(modified_lines) 655 | 656 | try: 657 | presets = ( 658 | ["@babel/preset-react"] 659 | if self.current_file_path.endswith(".jsx") 660 | else ["@babel/preset-env"] 661 | ) 662 | 663 | cmd = [ 664 | "npx", 665 | "babel", 666 | "--presets", 667 | ",".join(presets), 668 | "--no-babelrc", 669 | temp_path, 670 | "--out-file", 671 | "/dev/null", # Output to nowhere, we just want to check syntax 672 | ] 673 | 674 | # Execute Babel to transform (which validates syntax) 675 | process = subprocess.run(cmd, capture_output=True, text=True) 676 | 677 | if process.returncode != 0: 678 | error_output = process.stderr 679 | 680 | filtered_lines = [] 681 | for line in error_output.split("\n"): 682 | if "node_modules/@babel" not in line: 683 | filtered_lines.append(line) 684 | 685 | filtered_error = "\n".join(filtered_lines).strip() 686 | 687 | if not filtered_error: 688 | filtered_error = "JavaScript syntax error detected" 689 | 690 | error = { 691 | "error": f"JavaScript syntax error: {filtered_error}", 692 | "diff_lines": diff_result, 693 | "auto_cancel": self.fail_on_js_syntax_error, 694 | } 695 | 696 | except Exception as e: 697 | os.unlink(temp_path) 698 | error = { 699 | "error": f"Error checking JavaScript syntax: {str(e)}", 700 | "diff_lines": diff_result, 701 | } 702 | 703 | finally: 704 | if os.path.exists(temp_path): 705 | os.unlink(temp_path) 706 | 707 | self.pending_modified_lines = modified_lines 708 | self.pending_diff = diff_result 709 | 710 | result = { 711 | "status": "preview", 712 | "message": "Changes ready to apply. Use confirm() to apply or cancel() to discard.", 713 | "diff_lines": diff_result["diff_lines"], 714 | "start": start, 715 | "end": end, 716 | } 717 | if error: 718 | result.update(error) 719 | if error.get("auto_cancel", False): 720 | self.pending_modified_lines = None 721 | self.pending_diff = None 722 | result["status"] = "auto_cancelled" 723 | result["message"] = ( 724 | "Changes automatically cancelled due to syntax error. The lines are still selected." 725 | ) 726 | else: 727 | result["message"] += ( 728 | " It looks like there is a syntax error, but you can choose to fix it in the subsequent edits." 729 | ) 730 | 731 | return result 732 | 733 | # Later on this tool should be shown conditionally, however, most clients don't support this functionality yet. 734 | @self.mcp.tool() 735 | async def confirm() -> Dict[str, Any]: 736 | """Confirm action""" 737 | if self.pending_modified_lines is None or self.pending_diff is None: 738 | return {"error": "No pending changes to apply. Use overwrite first."} 739 | 740 | try: 741 | with open(self.current_file_path, "w", encoding="utf-8") as file: 742 | file.writelines(self.pending_modified_lines) 743 | 744 | result = { 745 | "status": "success", 746 | "message": f"Changes applied successfully.", 747 | } 748 | 749 | self.selected_start = None 750 | self.selected_end = None 751 | self.selected_id = None 752 | self.pending_modified_lines = None 753 | self.pending_diff = None 754 | 755 | return result 756 | except Exception as e: 757 | return {"error": f"Error writing to file: {str(e)}"} 758 | 759 | @self.mcp.tool() 760 | async def cancel() -> Dict[str, Any]: 761 | """ 762 | Cancel action 763 | """ 764 | if self.pending_modified_lines is None or self.pending_diff is None: 765 | return {"error": "No pending changes to discard. Use overwrite first."} 766 | 767 | self.pending_modified_lines = None 768 | self.pending_diff = None 769 | 770 | return { 771 | "status": "success", 772 | "message": "Action cancelled.", 773 | } 774 | 775 | @self.mcp.tool() 776 | async def delete_file() -> Dict[str, Any]: 777 | """ 778 | Delete current file 779 | """ 780 | 781 | if self.current_file_path is None: 782 | return {"error": "No file path is set. Use set_file first."} 783 | 784 | try: 785 | if not os.path.exists(self.current_file_path): 786 | return {"error": f"File '{self.current_file_path}' does not exist."} 787 | 788 | os.remove(self.current_file_path) 789 | 790 | deleted_path = self.current_file_path 791 | 792 | self.current_file_path = None 793 | 794 | return { 795 | "status": "success", 796 | "message": f"File '{deleted_path}' was successfully deleted.", 797 | } 798 | except Exception as e: 799 | return {"error": f"Error deleting file: {str(e)}"} 800 | 801 | @self.mcp.tool() 802 | async def new_file(filepath: str) -> Dict[str, Any]: 803 | """ 804 | Creates a new file. 805 | 806 | After creating new file, the first line is automatically selected for editing. 807 | Automatically creates parent directories if they don't exist. 808 | 809 | Args: 810 | filepath (str): Path of the new file 811 | Returns: 812 | dict: Status message with selection info 813 | 814 | """ 815 | self.current_file_path = filepath 816 | 817 | if ( 818 | os.path.exists(self.current_file_path) 819 | and os.path.getsize(self.current_file_path) > 0 820 | ): 821 | return { 822 | "error": "Cannot create new file. Current file exists and is not empty." 823 | } 824 | 825 | try: 826 | # Create parent directories if they don't exist 827 | directory = os.path.dirname(self.current_file_path) 828 | if directory: 829 | os.makedirs(directory, exist_ok=True) 830 | 831 | text = "# NEW_FILE - REMOVE THIS HEADER" 832 | with open(self.current_file_path, "w", encoding="utf-8") as file: 833 | file.write(text) 834 | 835 | # Automatically select the first line for editing 836 | self.selected_start = 1 837 | self.selected_end = 1 838 | self.selected_id = calculate_id(text, 1, 1) 839 | 840 | result = { 841 | "status": "success", 842 | "text": text, 843 | "current_file_path": self.current_file_path, 844 | "id": self.selected_id, 845 | "selected_start": self.selected_start, 846 | "selected_end": self.selected_end, 847 | "message": "File created successfully. First line is now selected for editing.", 848 | } 849 | 850 | return result 851 | except Exception as e: 852 | return {"error": f"Error creating file: {str(e)}"} 853 | 854 | @self.mcp.tool() 855 | async def find_line( 856 | search_text: str, 857 | ) -> Dict[str, Any]: 858 | """ 859 | Find lines that match provided text in the current file. 860 | 861 | Args: 862 | search_text (str): Text to search for in the file 863 | 864 | Returns: 865 | dict: Matching lines with their line numbers, and full text 866 | """ 867 | if self.current_file_path is None: 868 | return {"error": "No file path is set. Use set_file first."} 869 | 870 | try: 871 | with open(self.current_file_path, "r", encoding="utf-8") as file: 872 | lines = file.readlines() 873 | 874 | matches = [] 875 | for i, line in enumerate(lines, start=1): 876 | if search_text in line: 877 | matches.append([i, line]) 878 | 879 | result = { 880 | "status": "success", 881 | "matches": matches, 882 | "total_matches": len(matches), 883 | } 884 | 885 | return result 886 | 887 | except Exception as e: 888 | return {"error": f"Error searching file: {str(e)}"} 889 | 890 | @self.mcp.tool() 891 | async def listdir(dirpath: str) -> Dict[str, Any]: 892 | try: 893 | return { 894 | "filenames": os.listdir(dirpath), 895 | "path": dirpath, 896 | } 897 | except NotADirectoryError as e: 898 | return { 899 | "error": "Specified path is not a directory.", 900 | "path": dirpath, 901 | } 902 | except Exception as e: 903 | return { 904 | "error": f"Unexpected error when listing the directory: {str(e)}" 905 | } 906 | 907 | @self.mcp.tool() 908 | async def find_function( 909 | function_name: str, 910 | ) -> Dict[str, Any]: 911 | """ 912 | Find a function or method definition in a Python or JS/JSX file. Uses AST parsers. 913 | 914 | Args: 915 | function_name (str): Name of the function or method to find 916 | 917 | Returns: 918 | dict: function lines with their line numbers, start_line, and end_line 919 | """ 920 | if self.current_file_path is None: 921 | return {"error": "No file path is set. Use set_file first."} 922 | 923 | # Check if the file is a supported type (Python or JavaScript/JSX) 924 | is_python = self.current_file_path.endswith(".py") 925 | is_javascript = self.current_file_path.endswith((".js", ".jsx")) 926 | 927 | if not (is_python or is_javascript): 928 | return { 929 | "error": "This tool only works with Python (.py) or JavaScript/JSX (.js, .jsx) files." 930 | } 931 | 932 | try: 933 | with open(self.current_file_path, "r", encoding="utf-8") as file: 934 | source_code = file.read() 935 | lines = source_code.splitlines(True) # Keep line endings 936 | 937 | # Process JavaScript/JSX files 938 | if is_javascript: 939 | return self._find_js_function(function_name, source_code, lines) 940 | 941 | # For Python files, parse the source code to AST 942 | tree = ast.parse(source_code) 943 | 944 | # Find the function in the AST 945 | function_node = None 946 | class_node = None 947 | parent_function = None 948 | 949 | # Helper function to find a function or method node 950 | def find_node(node): 951 | nonlocal function_node, class_node, parent_function 952 | if isinstance(node, ast.FunctionDef) and node.name == function_name: 953 | function_node = node 954 | return True 955 | # Check for methods in classes 956 | elif isinstance(node, ast.ClassDef): 957 | for item in node.body: 958 | if ( 959 | isinstance(item, ast.FunctionDef) 960 | and item.name == function_name 961 | ): 962 | function_node = item 963 | class_node = node 964 | return True 965 | # Check for nested functions 966 | elif isinstance(node, ast.FunctionDef): 967 | for item in node.body: 968 | # Find directly nested function definitions 969 | if ( 970 | isinstance(item, ast.FunctionDef) 971 | and item.name == function_name 972 | ): 973 | function_node = item 974 | # Store parent function information 975 | parent_function = node 976 | return True 977 | # Recursively search for nested functions/methods 978 | for child in ast.iter_child_nodes(node): 979 | if find_node(child): 980 | return True 981 | return False 982 | 983 | # Search for the function in the AST 984 | find_node(tree) 985 | 986 | if not function_node: 987 | return { 988 | "error": f"Function or method '{function_name}' not found in the file." 989 | } 990 | 991 | # Get the line range for the function 992 | start_line = function_node.lineno 993 | end_line = 0 994 | 995 | # Find the end line by looking at tokens 996 | with open(self.current_file_path, "rb") as file: 997 | tokens = list(tokenize.tokenize(file.readline)) 998 | 999 | # Find the function definition token 1000 | function_def_index = -1 1001 | for i, token in enumerate(tokens): 1002 | if token.type == tokenize.NAME and token.string == function_name: 1003 | if ( 1004 | i > 0 1005 | and tokens[i - 1].type == tokenize.NAME 1006 | and tokens[i - 1].string == "def" 1007 | ): 1008 | function_def_index = i 1009 | break 1010 | 1011 | if function_def_index == -1: 1012 | # Fallback - use AST to determine the end 1013 | # First, get the end_lineno from the function node itself 1014 | end_line = function_node.end_lineno or start_line 1015 | # Then walk through all nodes inside the function to find the deepest end_lineno 1016 | # This handles nested functions and statements properly 1017 | # Walk through all nodes inside the function to find the deepest end_lineno 1018 | # This handles nested functions and statements properly 1019 | for node in ast.walk(function_node): 1020 | if hasattr(node, "end_lineno") and node.end_lineno: 1021 | end_line = max(end_line, node.end_lineno) 1022 | 1023 | # Specifically look for nested function definitions 1024 | # by checking for FunctionDef nodes within the function body 1025 | for node in ast.walk(function_node): 1026 | if ( 1027 | isinstance(node, ast.FunctionDef) 1028 | and node is not function_node 1029 | ): 1030 | if hasattr(node, "end_lineno") and node.end_lineno: 1031 | end_line = max(end_line, node.end_lineno) 1032 | else: 1033 | # Find the closing token of the function (either the next function/class at the same level or the end of file) 1034 | indent_level = tokens[function_def_index].start[ 1035 | 1 1036 | ] # Get the indentation of the function 1037 | in_function = False 1038 | nested_level = 0 1039 | for token in tokens[function_def_index + 1 :]: 1040 | current_line = token.start[0] 1041 | if current_line > start_line: 1042 | # Start tracking when we're inside the function body 1043 | if not in_function and token.string == ":": 1044 | in_function = True 1045 | continue 1046 | 1047 | # Track nested blocks by indentation 1048 | if in_function: 1049 | current_indent = token.start[1] 1050 | # Find a token at the same indentation level as the function definition 1051 | # but only if we're not in a nested block 1052 | if ( 1053 | current_indent <= indent_level 1054 | and token.type == tokenize.NAME 1055 | and token.string in ("def", "class") 1056 | and nested_level == 0 1057 | ): 1058 | end_line = current_line - 1 1059 | break 1060 | # Track nested blocks 1061 | elif ( 1062 | current_indent > indent_level 1063 | and token.type == tokenize.NAME 1064 | ): 1065 | if token.string in ("def", "class"): 1066 | nested_level += 1 1067 | # Look for the end of nested blocks 1068 | elif ( 1069 | nested_level > 0 and current_indent <= indent_level 1070 | ): 1071 | nested_level -= 1 1072 | 1073 | # If we couldn't find the end, use the last line of the file 1074 | if end_line == 0: 1075 | end_line = len(lines) 1076 | 1077 | # Include decorators if present 1078 | for decorator in function_node.decorator_list: 1079 | start_line = min(start_line, decorator.lineno) 1080 | 1081 | # Adjust for methods inside classes 1082 | if class_node: 1083 | class_body_start = min( 1084 | item.lineno 1085 | for item in class_node.body 1086 | if hasattr(item, "lineno") 1087 | ) 1088 | if function_node.lineno == class_body_start: 1089 | # If this is the first method, include the class definition 1090 | start_line = class_node.lineno 1091 | 1092 | # Normalize line numbers (1-based for API consistency) 1093 | function_lines = lines[start_line - 1 : end_line] 1094 | 1095 | # Format the results similar to the read tool 1096 | formatted_lines = [] 1097 | for i, line in enumerate(function_lines, start_line): 1098 | formatted_lines.append((i, line.rstrip())) 1099 | 1100 | result = { 1101 | "status": "success", 1102 | "lines": formatted_lines, 1103 | "start_line": start_line, 1104 | "end_line": end_line, 1105 | } 1106 | 1107 | # Add parent function information if this is a nested function 1108 | if parent_function: 1109 | result["is_nested"] = True 1110 | result["parent_function"] = parent_function.name 1111 | 1112 | return result 1113 | 1114 | except Exception as e: 1115 | return {"error": f"Error finding function: {str(e)}"} 1116 | 1117 | @self.mcp.tool() 1118 | async def set_python_path(path: str): 1119 | """ 1120 | Set it before running tests so the project is correctly recognized 1121 | """ 1122 | os.environ["PYTHONPATH"] = path 1123 | 1124 | @self.mcp.tool() 1125 | async def run_tests( 1126 | test_path: Optional[str] = None, 1127 | test_name: Optional[str] = None, 1128 | verbose: bool = False, 1129 | collect_only: bool = False, 1130 | ) -> Dict[str, Any]: 1131 | """ 1132 | Run pytest tests using the specified Python virtual environment. 1133 | 1134 | Args: 1135 | test_path (str, optional): Directory or file path containing tests to run 1136 | test_name (str, optional): Specific test function/method to run 1137 | verbose (bool, optional): Run tests in verbose mode 1138 | collect_only (bool, optional): Only collect tests without executing them 1139 | 1140 | Returns: 1141 | dict: Test execution results including returncode, output, and execution time 1142 | """ 1143 | if self.python_venv is None: 1144 | return { 1145 | "error": "No Python environment found. It needs to be set in the MCP config as environment variable called PYTHON_VENV." 1146 | } 1147 | # Build pytest arguments 1148 | pytest_args = [] 1149 | 1150 | # Add test path if specified 1151 | if test_path: 1152 | pytest_args.append(test_path) 1153 | 1154 | # Add specific test name if specified 1155 | if test_name: 1156 | pytest_args.append(f"-k {test_name}") 1157 | 1158 | # Add verbosity flag if specified 1159 | if verbose: 1160 | pytest_args.append("-v") 1161 | 1162 | # Add collect-only flag if specified 1163 | if collect_only: 1164 | pytest_args.append("--collect-only") 1165 | 1166 | # Run the tests 1167 | return self._run_tests(pytest_args) 1168 | 1169 | def _find_js_function( 1170 | self, function_name: str, source_code: str, lines: list 1171 | ) -> Dict[str, Any]: 1172 | """ 1173 | Helper method to find JavaScript/JSX function definitions using Babel AST parsing. 1174 | 1175 | Args: 1176 | function_name (str): Name of the function to find 1177 | source_code (str): Source code content 1178 | lines (list): Source code split by lines with line endings preserved 1179 | 1180 | Returns: 1181 | dict: Dictionary with function information 1182 | """ 1183 | try: 1184 | # First try using Babel for accurate parsing if it's available 1185 | if self.enable_js_syntax_check: 1186 | babel_result = self._find_js_function_babel( 1187 | function_name, source_code, lines 1188 | ) 1189 | if babel_result and not babel_result.get("error"): 1190 | return babel_result 1191 | 1192 | # Fallback to regex approach if Babel parsing fails or is disabled 1193 | # Pattern for named function declaration 1194 | # Matches: function functionName(args) { body } 1195 | # Also matches: async function functionName(args) { body } 1196 | function_pattern = re.compile( 1197 | r"(?:async\s+)?function\s+(?P\w+)\s*\((?P[^()]*)\)\s*{", 1198 | re.MULTILINE, 1199 | ) 1200 | 1201 | # Pattern for arrow functions with explicit name 1202 | # Matches: const functionName = (args) => { body } or const functionName = args => { body } 1203 | # Also matches async variants: const functionName = async (args) => { body } 1204 | # Also matches component inner functions: const innerFunction = async () => { ... } 1205 | arrow_pattern = re.compile( 1206 | r"(?:(?:const|let|var)\s+)?(?P\w+)\s*=\s*(?:async\s+)?(?:\((?P[^()]*)\)|(?P\w+))\s*=>\s*{", 1207 | re.MULTILINE, 1208 | ) 1209 | 1210 | # Pattern for object method definitions 1211 | # Matches: functionName(args) { body } in object literals or classes 1212 | # Also matches: async functionName(args) { body } 1213 | method_pattern = re.compile( 1214 | r"(?:^|,|{)\s*(?:async\s+)?(?P\w+)\s*\((?P[^()]*)\)\s*{", 1215 | re.MULTILINE, 1216 | ) 1217 | 1218 | # Pattern for React hooks like useCallback, useEffect, etc. 1219 | # Matches: const functionName = useCallback(async () => { ... }, [deps]) 1220 | hook_pattern = re.compile( 1221 | r"const\s+(?P\w+)\s*=\s*use\w+\((?:async\s+)?\(?[^{]*\)?\s*=>[^{]*{", 1222 | re.MULTILINE, 1223 | ) 1224 | 1225 | # Search for the function 1226 | matches = [] 1227 | 1228 | # Check all patterns 1229 | for pattern in [ 1230 | function_pattern, 1231 | arrow_pattern, 1232 | method_pattern, 1233 | hook_pattern, 1234 | ]: 1235 | for match in pattern.finditer(source_code): 1236 | if match.groupdict().get("functionName") == function_name: 1237 | matches.append(match) 1238 | 1239 | if not matches: 1240 | return {"error": f"Function '{function_name}' not found in the file."} 1241 | 1242 | # Use the first match 1243 | match = matches[0] 1244 | start_pos = match.start() 1245 | 1246 | # Find the line number for the start 1247 | start_line = 1 1248 | pos = 0 1249 | for i, line in enumerate(lines, 1): 1250 | next_pos = pos + len(line) 1251 | if pos <= start_pos < next_pos: 1252 | start_line = i 1253 | break 1254 | pos = next_pos 1255 | 1256 | # Find the closing brace that matches the opening brace of the function 1257 | # Count the number of opening and closing braces 1258 | brace_count = 0 1259 | end_pos = start_pos 1260 | in_string = False 1261 | string_delimiter = None 1262 | escaped = False 1263 | 1264 | for i in range(start_pos, len(source_code)): 1265 | char = source_code[i] 1266 | 1267 | # Handle strings to avoid counting braces inside strings 1268 | if not escaped and char in ['"', "'", "`"]: 1269 | if not in_string: 1270 | in_string = True 1271 | string_delimiter = char 1272 | elif char == string_delimiter: 1273 | in_string = False 1274 | 1275 | # Check for escape character 1276 | if char == "\\" and not escaped: 1277 | escaped = True 1278 | continue 1279 | 1280 | escaped = False 1281 | 1282 | # Only count braces outside of strings 1283 | if not in_string: 1284 | if char == "{": 1285 | brace_count += 1 1286 | elif char == "}": 1287 | brace_count -= 1 1288 | if brace_count == 0: 1289 | end_pos = i + 1 # Include the closing brace 1290 | break 1291 | 1292 | # Find the end line number 1293 | end_line = 1 1294 | pos = 0 1295 | for i, line in enumerate(lines, 1): 1296 | next_pos = pos + len(line) 1297 | if pos <= end_pos < next_pos: 1298 | end_line = i 1299 | break 1300 | pos = next_pos 1301 | 1302 | # Extract the function lines 1303 | function_lines = lines[start_line - 1 : end_line] 1304 | 1305 | # Format results like the read tool 1306 | formatted_lines = [] 1307 | for i, line in enumerate(function_lines, start_line): 1308 | formatted_lines.append((i, line.rstrip())) 1309 | 1310 | result = { 1311 | "status": "success", 1312 | "lines": formatted_lines, 1313 | "start_line": start_line, 1314 | "end_line": end_line, 1315 | } 1316 | 1317 | return result 1318 | 1319 | except Exception as e: 1320 | return {"error": f"Error finding JavaScript function: {str(e)}"} 1321 | 1322 | def _find_js_function_babel( 1323 | self, function_name: str, source_code: str, lines: list 1324 | ) -> Dict[str, Any]: 1325 | """ 1326 | Use Babel to parse JavaScript/JSX code and find function definitions. 1327 | 1328 | This provides more accurate function location by using proper AST parsing 1329 | rather than regex pattern matching. 1330 | 1331 | Args: 1332 | function_name (str): Name of the function to find 1333 | source_code (str): Source code content 1334 | lines (list): Source code split by lines with line endings preserved 1335 | 1336 | Returns: 1337 | dict: Dictionary with function information or None if Babel fails 1338 | """ 1339 | try: 1340 | # Create a temporary file with the source code 1341 | with tempfile.NamedTemporaryFile( 1342 | mode="w", suffix=".jsx", delete=False 1343 | ) as temp: 1344 | temp_path = temp.name 1345 | temp.write(source_code) 1346 | 1347 | # Determine the appropriate Babel preset 1348 | is_jsx = self.current_file_path.endswith(".jsx") 1349 | presets = ["@babel/preset-react"] if is_jsx else ["@babel/preset-env"] 1350 | 1351 | # Use Babel to output the AST as JSON 1352 | cmd = [ 1353 | "npx", 1354 | "babel", 1355 | "--presets", 1356 | ",".join(presets), 1357 | "--plugins", 1358 | # Add the AST plugin that outputs function locations 1359 | "babel-plugin-ast-function-metadata", 1360 | "--no-babelrc", 1361 | temp_path, 1362 | "--out-file", 1363 | "/dev/null", # Output to nowhere, we just want the AST metadata 1364 | ] 1365 | 1366 | # Execute Babel to get the AST with function locations 1367 | process = subprocess.run(cmd, capture_output=True, text=True) 1368 | 1369 | # Clean up the temporary file 1370 | try: 1371 | os.unlink(temp_path) 1372 | except: 1373 | pass 1374 | 1375 | # If Babel execution failed, return None to fall back to regex 1376 | if process.returncode != 0: 1377 | return None 1378 | 1379 | # Parse the output to find location data 1380 | output = process.stdout 1381 | # Look for the JSON that has our function location data 1382 | location_data = None 1383 | import json 1384 | 1385 | try: 1386 | # Extract the JSON output from Babel plugin 1387 | # Format is typically like: FUNCTION_LOCATIONS: {... json data ...} 1388 | match = re.search(r"FUNCTION_LOCATIONS:\s*({.*})", output, re.DOTALL) 1389 | if match: 1390 | json_str = match.group(1) 1391 | locations = json.loads(json_str) 1392 | # Find our specific function 1393 | location_data = locations.get(function_name) 1394 | except (json.JSONDecodeError, AttributeError) as e: 1395 | return None 1396 | 1397 | if not location_data: 1398 | return None 1399 | 1400 | # Get the line information from the location data 1401 | start_line = location_data.get("start", {}).get("line", 0) 1402 | end_line = location_data.get("end", {}).get("line", 0) 1403 | 1404 | if start_line <= 0 or end_line <= 0: 1405 | return None 1406 | 1407 | # Extract the function lines 1408 | function_lines = lines[start_line - 1 : end_line] 1409 | 1410 | # Format results like the read tool 1411 | formatted_lines = [] 1412 | for i, line in enumerate(function_lines, start_line): 1413 | formatted_lines.append((i, line.rstrip())) 1414 | 1415 | result = { 1416 | "status": "success", 1417 | "lines": formatted_lines, 1418 | "start_line": start_line, 1419 | "end_line": end_line, 1420 | "parser": "babel", # Flag that this was parsed with Babel 1421 | } 1422 | 1423 | return result 1424 | 1425 | except Exception as e: 1426 | # If anything goes wrong, return None to fall back to regex approach 1427 | return None 1428 | 1429 | def _run_tests(self, pytest_args=None, python_venv=None): 1430 | """ 1431 | Run pytest tests using the specified Python virtual environment. 1432 | 1433 | Args: 1434 | pytest_args (list, optional): List of arguments to pass to pytest 1435 | python_venv (str, optional): Path to Python executable in virtual environment 1436 | If not provided, uses PYTHON_VENV environment variable 1437 | 1438 | Returns: 1439 | dict: Test execution results including returncode, output, and execution time 1440 | """ 1441 | try: 1442 | # Determine the Python executable to use 1443 | python_venv = ( 1444 | python_venv or self.python_venv 1445 | ) # Use the class level python_venv 1446 | 1447 | # If no venv is specified, use the system Python 1448 | python_cmd = python_venv or "python" 1449 | 1450 | # Build the command to run pytest 1451 | cmd = [python_cmd, "-m", "pytest"] 1452 | 1453 | # Add any additional pytest arguments 1454 | if pytest_args: 1455 | cmd.extend(pytest_args) 1456 | 1457 | # Record the start time 1458 | start_time = datetime.datetime.now() 1459 | 1460 | # Run the pytest command 1461 | process = subprocess.run(cmd, capture_output=True, text=True) 1462 | 1463 | # Record the end time and calculate duration 1464 | end_time = datetime.datetime.now() 1465 | duration = (end_time - start_time).total_seconds() 1466 | 1467 | # Return the results 1468 | return { 1469 | "status": "success" if process.returncode == 0 else "failure", 1470 | "returncode": process.returncode, 1471 | "stdout": process.stdout, 1472 | "stderr": process.stderr, 1473 | "duration": duration, 1474 | "command": " ".join(cmd), 1475 | } 1476 | except Exception as e: 1477 | return { 1478 | "status": "error", 1479 | "error": str(e), 1480 | "command": " ".join(cmd) if "cmd" in locals() else None, 1481 | } 1482 | 1483 | def run(self, transport="stdio", **transport_kwargs): 1484 | """Run the MCP server.""" 1485 | self.mcp.run(transport=transport, **transport_kwargs) 1486 | 1487 | 1488 | def main(): 1489 | """Entry point for the application. 1490 | 1491 | This function is used both for direct execution and 1492 | when the package is installed via UVX, allowing the 1493 | application to be run using the `editor-mcp` command. 1494 | """ 1495 | 1496 | parser = argparse.ArgumentParser(description="Text Editor MCP Server") 1497 | parser.add_argument( 1498 | "--transport", 1499 | default="stdio", 1500 | choices=["stdio", "http", "streamable-http"], 1501 | help="Transport type", 1502 | ) 1503 | parser.add_argument( 1504 | "--host", default="127.0.0.1", help="Host to bind to (for HTTP transport)" 1505 | ) 1506 | parser.add_argument( 1507 | "--port", type=int, default=8001, help="Port to bind to (for HTTP transport)" 1508 | ) 1509 | parser.add_argument( 1510 | "--path", default="/mcp", help="Path for HTTP endpoint (for HTTP transport)" 1511 | ) 1512 | 1513 | args = parser.parse_args() 1514 | 1515 | host = os.environ.get("FASTMCP_SERVER_HOST", args.host) 1516 | port = int(os.environ.get("FASTMCP_SERVER_PORT", args.port)) 1517 | path = os.environ.get("FASTMCP_SERVER_PATH", args.path) 1518 | transport = os.environ.get("FASTMCP_SERVER_TRANSPORT", args.transport) 1519 | 1520 | # Normalize transport name for FastMCP 1521 | if transport in ["http", "streamable-http"]: 1522 | transport = "streamable-http" 1523 | 1524 | # Run the server with the configured transport 1525 | if transport == "streamable-http": 1526 | text_editor_server.run(transport=transport, host=host, port=port, path=path) 1527 | else: 1528 | text_editor_server.run(transport=transport) 1529 | 1530 | 1531 | text_editor_server = TextEditorServer() 1532 | mcp = text_editor_server.mcp 1533 | if __name__ == "__main__": 1534 | main() 1535 | -------------------------------------------------------------------------------- /system_prompt.md: -------------------------------------------------------------------------------- 1 | When editing code, remember to always do small edits. It can be daunting sometimes but you've got this! 2 | 3 | Take a modular approach: 4 | * Overwrite individual lines for smallest changes 5 | * Overwrite section of a function for bigger changes 6 | * Overwrite a function for biggest changes 7 | 8 | Aim to keep the code working between the changes 9 | 10 | Never overwrite the whole files bigger than 50 lines. I know it's easier but this uses a lot of resources and can lead to a complete shutdown. 11 | 12 | After you're finished with the task, always do a final skim of the file you were editing and check for any syntax problems, missing parts etc. Just a final check, don't need to make any changes if everything looks OK. -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | # This file can be used to define fixtures and other test configuration 4 | # that will be available to all test files 5 | 6 | # Enable asyncio support for pytest 7 | pytest_plugins = ["pytest_asyncio"] 8 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import tempfile 4 | import hashlib 5 | 6 | 7 | from src.text_editor.server import TextEditorServer, calculate_id, generate_diff_preview 8 | from mcp.server.fastmcp import FastMCP 9 | 10 | 11 | class TestTextEditorServer: 12 | @pytest.fixture 13 | def server(self, monkeypatch): 14 | """Create a TextEditorServer instance for testing.""" 15 | monkeypatch.setenv("PYTHON_VENV", "python") 16 | server = TextEditorServer() 17 | server.max_select_lines = 200 18 | return server 19 | 20 | @pytest.fixture 21 | def temp_file(self): 22 | """Create a temporary file for testing.""" 23 | content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n" 24 | with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: 25 | f.write(content) 26 | temp_path = f.name 27 | yield temp_path 28 | if os.path.exists(temp_path): 29 | os.unlink(temp_path) 30 | 31 | @pytest.fixture 32 | def empty_temp_file(self): 33 | """Create an empty temporary file for testing.""" 34 | with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: 35 | temp_path = f.name 36 | yield temp_path 37 | if os.path.exists(temp_path): 38 | os.unlink(temp_path) 39 | 40 | @pytest.fixture 41 | def server_with_protected_paths(self): 42 | """Create a TextEditorServer instance with protected paths configuration.""" 43 | server = TextEditorServer() 44 | server.max_select_lines = 200 45 | # Define protected paths for testing 46 | server.protected_paths = ["*.env", "/etc/passwd", "/home/secret-file.txt"] 47 | return server 48 | 49 | def get_tool_fn(self, server, tool_name): 50 | """Helper to get the tool function from the server.""" 51 | tools_dict = server.mcp._tool_manager._tools 52 | return tools_dict[tool_name].fn 53 | 54 | @pytest.mark.asyncio 55 | async def test_set_file_valid(self, server, temp_file): 56 | """Test setting a valid file path.""" 57 | set_file_fn = self.get_tool_fn(server, "set_file") 58 | result = await set_file_fn(temp_file) 59 | assert "File set to:" in result 60 | assert temp_file in result 61 | assert server.current_file_path == temp_file 62 | 63 | @pytest.mark.asyncio 64 | async def test_set_file_protected_path_exact_match( 65 | self, server_with_protected_paths 66 | ): 67 | """Test setting a file path that exactly matches a protected path.""" 68 | set_file_fn = self.get_tool_fn(server_with_protected_paths, "set_file") 69 | result = await set_file_fn("/etc/passwd") 70 | assert "Error: Access to '/etc/passwd' is denied" in result 71 | assert server_with_protected_paths.current_file_path is None 72 | 73 | @pytest.mark.asyncio 74 | async def test_set_file_protected_path_wildcard_match( 75 | self, server_with_protected_paths, monkeypatch 76 | ): 77 | """Test setting a file path that matches a wildcard protected path pattern.""" 78 | # Create a temporary .env file for testing 79 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".env", delete=False) as f: 80 | f.write("API_KEY=test_key\n") 81 | env_file_path = f.name 82 | 83 | # Mock os.path.isfile to return True for our temp file 84 | def mock_isfile(path): 85 | return path == env_file_path 86 | 87 | monkeypatch.setattr(os.path, "isfile", mock_isfile) 88 | 89 | try: 90 | set_file_fn = self.get_tool_fn(server_with_protected_paths, "set_file") 91 | result = await set_file_fn(env_file_path) 92 | assert "Error: Access to '" in result 93 | assert ( 94 | "is denied due to PROTECTED_PATHS configuration (matches pattern '*.env')" 95 | in result 96 | ) 97 | assert server_with_protected_paths.current_file_path is None 98 | finally: 99 | if os.path.exists(env_file_path): 100 | os.unlink(env_file_path) 101 | 102 | @pytest.mark.asyncio 103 | async def test_set_file_protected_path_glob_match(self, monkeypatch): 104 | """Test setting a file path that matches a more complex glob pattern.""" 105 | # Create a server with different glob patterns 106 | server = TextEditorServer() 107 | server.protected_paths = [".env*", "config*.json", "*keys.txt"] 108 | 109 | # Create a temporary .env.local file for testing 110 | with tempfile.NamedTemporaryFile( 111 | mode="w+", prefix=".env", suffix=".local", delete=False 112 | ) as f: 113 | f.write("API_KEY=test_key\n") 114 | env_local_path = f.name 115 | 116 | # Create a temporary config-dev.json file for testing 117 | with tempfile.NamedTemporaryFile( 118 | mode="w+", prefix="config-", suffix=".json", delete=False 119 | ) as f: 120 | f.write('{"debug": true}\n') 121 | config_path = f.name 122 | 123 | # Create a custom filename that will definitely match our pattern 124 | keys_file_path = os.path.join(tempfile.gettempdir(), "api-keys.txt") 125 | with open(keys_file_path, "w") as f: 126 | f.write("secret_key=abc123\n") 127 | secret_path = keys_file_path 128 | 129 | # Mock os.path.isfile to return True for our test files 130 | def mock_isfile(path): 131 | return path in [env_local_path, config_path, secret_path] 132 | 133 | monkeypatch.setattr(os.path, "isfile", mock_isfile) 134 | 135 | try: 136 | set_file_fn = self.get_tool_fn(server, "set_file") 137 | 138 | # Test .env* pattern 139 | result = await set_file_fn(env_local_path) 140 | assert "Error: Access to '" in result 141 | assert ( 142 | "is denied due to PROTECTED_PATHS configuration (matches pattern '.env*'" 143 | in result 144 | ) 145 | assert server.current_file_path is None 146 | 147 | # Test config*.json pattern 148 | result = await set_file_fn(config_path) 149 | assert "Error: Access to '" in result 150 | assert ( 151 | "is denied due to PROTECTED_PATHS configuration (matches pattern 'config*.json'" 152 | in result 153 | ) 154 | assert server.current_file_path is None 155 | 156 | # Test *keys.txt pattern 157 | result = await set_file_fn(secret_path) 158 | assert "Error: Access to '" in result 159 | assert ( 160 | "is denied due to PROTECTED_PATHS configuration (matches pattern '*keys.txt'" 161 | in result 162 | ) 163 | assert server.current_file_path is None 164 | finally: 165 | # Clean up temp files 166 | for path in [env_local_path, config_path, secret_path]: 167 | if os.path.exists(path): 168 | os.unlink(path) 169 | 170 | @pytest.mark.asyncio 171 | async def test_set_file_non_protected_path( 172 | self, server_with_protected_paths, temp_file 173 | ): 174 | """Test setting a file path that does not match any protected paths.""" 175 | set_file_fn = self.get_tool_fn(server_with_protected_paths, "set_file") 176 | result = await set_file_fn(temp_file) 177 | assert "File set to:" in result 178 | assert temp_file in result 179 | assert server_with_protected_paths.current_file_path == temp_file 180 | 181 | @pytest.mark.asyncio 182 | async def test_set_file_invalid(self, server): 183 | """Test setting a non-existent file path.""" 184 | set_file_fn = self.get_tool_fn(server, "set_file") 185 | non_existent_path = "/path/to/nonexistent/file.txt" 186 | result = await set_file_fn(non_existent_path) 187 | assert "Error: File not found" in result 188 | assert server.current_file_path is None 189 | 190 | @pytest.mark.asyncio 191 | async def test_read_no_file_set(self, server): 192 | """Test getting text when no file is set.""" 193 | read_fn = self.get_tool_fn(server, "read") 194 | result = await read_fn(1, 10) 195 | assert "error" in result 196 | assert "No file path is set" in result["error"] 197 | 198 | @pytest.mark.asyncio 199 | async def test_read_entire_file(self, server, temp_file): 200 | """Test getting the entire content of a file.""" 201 | set_file_fn = self.get_tool_fn(server, "set_file") 202 | await set_file_fn(temp_file) 203 | read_fn = self.get_tool_fn(server, "read") 204 | result = await read_fn(1, 5) 205 | assert "lines" in result 206 | 207 | async def test_read_line_range(self, server, temp_file): 208 | """Test getting a specific range of lines from a file.""" 209 | set_file_fn = self.get_tool_fn(server, "set_file") 210 | await set_file_fn(temp_file) 211 | read_fn = self.get_tool_fn(server, "read") 212 | result = await read_fn(2, 4) 213 | assert "lines" in result 214 | select_fn = self.get_tool_fn(server, "select") 215 | select_result = await select_fn(2, 4) 216 | assert "status" in select_result 217 | assert "id" in select_result 218 | expected_id = calculate_id("Line 2\nLine 3\nLine 4\n", 2, 4) 219 | assert expected_id == select_result["id"] 220 | 221 | @pytest.mark.asyncio 222 | async def test_read_only_end_line(self, server, temp_file): 223 | """Test getting text with only end line specified.""" 224 | set_file_fn = self.get_tool_fn(server, "set_file") 225 | await set_file_fn(temp_file) 226 | read_fn = self.get_tool_fn(server, "read") 227 | result = await read_fn(1, 2) 228 | assert "lines" in result 229 | select_fn = self.get_tool_fn(server, "select") 230 | select_result = await select_fn(1, 2) 231 | expected_id = calculate_id("Line 1\nLine 2\n", 1, 2) 232 | assert expected_id == select_result["id"] 233 | 234 | @pytest.mark.asyncio 235 | async def test_read_invalid_range(self, server, temp_file): 236 | """Test getting text with an invalid line range.""" 237 | set_file_fn = self.get_tool_fn(server, "set_file") 238 | await set_file_fn(temp_file) 239 | read_fn = self.get_tool_fn(server, "read") 240 | result = await read_fn(4, 2) 241 | assert "error" in result 242 | # Updated assertion to match actual error message format in server.py 243 | assert "start=4 cannot be greater than end=2" in result["error"] 244 | result = await read_fn(0, 3) 245 | assert "error" in result 246 | assert "start must be at least 1" in result["error"] 247 | 248 | def test_calculate_id_function(self): 249 | """Test the calculate_id function directly.""" 250 | text = "Some test content" 251 | id_no_range = calculate_id(text) 252 | expected = hashlib.sha256(text.encode()).hexdigest()[:2] 253 | assert id_no_range == expected 254 | id_with_range = calculate_id(text, 1, 3) 255 | assert id_with_range.startswith("L1-3-") 256 | assert id_with_range.endswith(expected) 257 | 258 | @pytest.mark.asyncio 259 | async def test_read_large_file(self, server): 260 | """Test getting text from a file larger than MAX_SELECT_LINES lines.""" 261 | more_than_max_lines = server.max_select_lines + 10 262 | with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: 263 | for i in range(more_than_max_lines): 264 | f.write(f"Line {i + 1}\n") 265 | large_file_path = f.name 266 | try: 267 | set_file_fn = self.get_tool_fn(server, "set_file") 268 | await set_file_fn(large_file_path) 269 | read_fn = self.get_tool_fn(server, "read") 270 | result = await read_fn(1, more_than_max_lines) 271 | assert "lines" in result 272 | select_fn = self.get_tool_fn(server, "select") 273 | result = await select_fn(1, more_than_max_lines) 274 | assert "error" in result 275 | assert ( 276 | f"Cannot select more than {server.max_select_lines} lines at once" 277 | in result["error"] 278 | ) 279 | result = await select_fn(5, 15) 280 | assert "status" in result 281 | assert "id" in result 282 | result = await read_fn(5, server.max_select_lines + 10) 283 | assert "lines" in result 284 | finally: 285 | if os.path.exists(large_file_path): 286 | os.unlink(large_file_path) 287 | 288 | @pytest.mark.asyncio 289 | async def test_new_file(self, server, empty_temp_file): 290 | """Test new_file functionality.""" 291 | set_file_fn = self.get_tool_fn(server, "set_file") 292 | await set_file_fn(empty_temp_file) 293 | new_file_fn = self.get_tool_fn(server, "new_file") 294 | result = await new_file_fn(empty_temp_file) 295 | assert result["status"] == "success" 296 | assert "id" in result 297 | result = await new_file_fn(empty_temp_file) 298 | assert "error" in result 299 | 300 | @pytest.mark.asyncio 301 | async def test_delete_file(self, server): 302 | """Test delete_file tool.""" 303 | with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: 304 | f.write("Test content to delete") 305 | temp_path = f.name 306 | try: 307 | delete_file_fn = self.get_tool_fn(server, "delete_file") 308 | result = await delete_file_fn() 309 | assert "error" in result 310 | assert "No file path is set" in result["error"] 311 | set_file_fn = self.get_tool_fn(server, "set_file") 312 | await set_file_fn(temp_path) 313 | result = await delete_file_fn() 314 | assert result["status"] == "success" 315 | assert "successfully deleted" in result["message"] 316 | assert temp_path in result["message"] 317 | assert not os.path.exists(temp_path) 318 | assert server.current_file_path is None 319 | result = await set_file_fn(temp_path) 320 | assert "Error: File not found" in result 321 | assert server.current_file_path is None 322 | finally: 323 | if os.path.exists(temp_path): 324 | os.unlink(temp_path) 325 | 326 | @pytest.mark.asyncio 327 | async def test_delete_file_permission_error(self, server, monkeypatch): 328 | """Test delete_file with permission error.""" 329 | with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: 330 | f.write("Test content") 331 | temp_path = f.name 332 | try: 333 | set_file_fn = self.get_tool_fn(server, "set_file") 334 | await set_file_fn(temp_path) 335 | 336 | def mock_remove(path): 337 | raise PermissionError("Permission denied") 338 | 339 | monkeypatch.setattr(os, "remove", mock_remove) 340 | delete_file_fn = self.get_tool_fn(server, "delete_file") 341 | result = await delete_file_fn() 342 | assert "error" in result 343 | assert "Permission denied" in result["error"] 344 | assert server.current_file_path == temp_path 345 | finally: 346 | monkeypatch.undo() 347 | if os.path.exists(temp_path): 348 | os.unlink(temp_path) 349 | 350 | @pytest.mark.asyncio 351 | async def test_find_line_no_file_set(self, server): 352 | """Test find_line with no file set.""" 353 | find_line_fn = self.get_tool_fn(server, "find_line") 354 | result = await find_line_fn(search_text="Line") 355 | assert "error" in result 356 | assert "No file path is set" in result["error"] 357 | 358 | @pytest.mark.asyncio 359 | async def test_find_line_basic(self, server, temp_file): 360 | """Test basic find_line functionality.""" 361 | set_file_fn = self.get_tool_fn(server, "set_file") 362 | await set_file_fn(temp_file) 363 | find_line_fn = self.get_tool_fn(server, "find_line") 364 | result = await find_line_fn(search_text="Line") 365 | assert "status" in result 366 | assert result["status"] == "success" 367 | assert "matches" in result 368 | assert "total_matches" in result 369 | assert result["total_matches"] == 5 370 | for match in result["matches"]: 371 | # Each match is a list with [line_number, line_text] 372 | assert len(match) == 2 373 | assert isinstance(match[0], int) # line number 374 | assert isinstance(match[1], str) # line text 375 | assert f"Line {match[0]}" in match[1] 376 | line_numbers = [match[0] for match in result["matches"]] 377 | assert line_numbers == [1, 2, 3, 4, 5] 378 | 379 | @pytest.mark.asyncio 380 | async def test_find_line_specific_match(self, server, temp_file): 381 | """Test find_line with a specific search term.""" 382 | set_file_fn = self.get_tool_fn(server, "set_file") 383 | await set_file_fn(temp_file) 384 | find_line_fn = self.get_tool_fn(server, "find_line") 385 | result = await find_line_fn(search_text="Line 3") 386 | assert result["status"] == "success" 387 | assert result["total_matches"] == 1 388 | assert len(result["matches"]) == 1 389 | assert result["matches"][0][0] == 3 # First element is the line number 390 | assert "Line 3" in result["matches"][0][1] # Second element is the line text 391 | 392 | @pytest.mark.asyncio 393 | async def test_find_line_no_matches(self, server, temp_file): 394 | """Test find_line with a search term that doesn't exist.""" 395 | set_file_fn = self.get_tool_fn(server, "set_file") 396 | await set_file_fn(temp_file) 397 | find_line_fn = self.get_tool_fn(server, "find_line") 398 | result = await find_line_fn(search_text="NonExistentTerm") 399 | assert result["status"] == "success" 400 | assert result["total_matches"] == 0 401 | assert len(result["matches"]) == 0 402 | 403 | @pytest.mark.asyncio 404 | async def test_skim_no_file_set(self, server): 405 | """Test skim with no file set.""" 406 | skim_fn = self.get_tool_fn(server, "skim") 407 | result = await skim_fn() 408 | assert "error" in result 409 | assert "No file path is set" in result["error"] 410 | 411 | @pytest.mark.asyncio 412 | async def test_skim_basic(self, server, temp_file): 413 | """Test basic skim functionality.""" 414 | set_file_fn = self.get_tool_fn(server, "set_file") 415 | await set_file_fn(temp_file) 416 | skim_fn = self.get_tool_fn(server, "skim") 417 | result = await skim_fn() 418 | assert "lines" in result 419 | assert "total_lines" in result 420 | assert "max_select_lines" in result 421 | assert result["total_lines"] == 5 422 | assert result["max_select_lines"] == server.max_select_lines 423 | assert len(result["lines"]) == 5 424 | for i, line_data in enumerate(result["lines"], 1): 425 | assert line_data[0] == i # Check line number 426 | assert line_data[1] == f"Line {i}" # Check line content 427 | 428 | @pytest.mark.asyncio 429 | async def test_overwrite_no_selection(self, server, temp_file): 430 | """Test overwrite when no selection has been made.""" 431 | set_file_fn = self.get_tool_fn(server, "set_file") 432 | await set_file_fn(temp_file) 433 | overwrite_fn = self.get_tool_fn(server, "overwrite") 434 | result = await overwrite_fn(new_lines={"lines": ["New content"]}) 435 | assert "error" in result 436 | assert "No selection has been made" in result["error"] 437 | 438 | @pytest.mark.asyncio 439 | async def test_find_line_file_read_error(self, server, temp_file, monkeypatch): 440 | """Test find_line with a file read error.""" 441 | set_file_fn = self.get_tool_fn(server, "set_file") 442 | await set_file_fn(temp_file) 443 | 444 | def mock_open(*args, **kwargs): 445 | raise IOError("Mock file read error") 446 | 447 | monkeypatch.setattr("builtins.open", mock_open) 448 | find_line_fn = self.get_tool_fn(server, "find_line") 449 | result = await find_line_fn(search_text="Line") 450 | assert "error" in result 451 | assert "Error searching file" in result["error"] 452 | assert "Mock file read error" in result["error"] 453 | 454 | @pytest.mark.asyncio 455 | async def test_overwrite_no_file_set(self, server): 456 | """Test overwrite when no file is set.""" 457 | overwrite_fn = self.get_tool_fn(server, "overwrite") 458 | result = await overwrite_fn(new_lines={"lines": ["New content"]}) 459 | assert "error" in result 460 | assert "No file path is set" in result["error"] 461 | 462 | @pytest.mark.asyncio 463 | async def test_overwrite_basic(self, server, temp_file): 464 | """Test basic overwrite functionality.""" 465 | set_file_fn = self.get_tool_fn(server, "set_file") 466 | await set_file_fn(temp_file) 467 | select_fn = self.get_tool_fn(server, "select") 468 | select_result = await select_fn(2, 4) 469 | assert select_result["status"] == "success" 470 | assert "id" in select_result 471 | overwrite_fn = self.get_tool_fn(server, "overwrite") 472 | new_lines = {"lines": ["New Line 2", "New Line 3", "New Line 4"]} 473 | result = await overwrite_fn(new_lines=new_lines) 474 | assert "status" in result 475 | assert result["status"] == "preview" 476 | assert "Changes ready to apply" in result["message"] 477 | confirm_fn = self.get_tool_fn(server, "confirm") 478 | confirm_result = await confirm_fn() 479 | assert confirm_result["status"] == "success" 480 | assert "Changes applied successfully" in confirm_result["message"] 481 | with open(temp_file, "r") as f: 482 | file_content = f.read() 483 | expected_content = "Line 1\nNew Line 2\nNew Line 3\nNew Line 4\nLine 5\n" 484 | assert file_content == expected_content 485 | 486 | @pytest.mark.asyncio 487 | async def test_overwrite_cancel(self, server, temp_file): 488 | """Test overwrite with cancel operation.""" 489 | # Set up initial state 490 | set_file_fn = self.get_tool_fn(server, "set_file") 491 | await set_file_fn(temp_file) 492 | select_fn = self.get_tool_fn(server, "select") 493 | select_result = await select_fn(2, 4) 494 | assert select_result["status"] == "success" 495 | assert "id" in select_result 496 | 497 | # Create overwrite preview 498 | overwrite_fn = self.get_tool_fn(server, "overwrite") 499 | new_lines = {"lines": ["New Line 2", "New Line 3", "New Line 4"]} 500 | result = await overwrite_fn(new_lines=new_lines) 501 | assert "status" in result 502 | assert result["status"] == "preview" 503 | assert "Changes ready to apply" in result["message"] 504 | 505 | # Get original content to verify it remains unchanged 506 | with open(temp_file, "r") as f: 507 | original_content = f.read() 508 | 509 | # Cancel the changes 510 | cancel_fn = self.get_tool_fn(server, "cancel") 511 | cancel_result = await cancel_fn() 512 | assert cancel_result["status"] == "success" 513 | assert "Action cancelled" in cancel_result["message"] 514 | 515 | # Verify the file content is unchanged 516 | with open(temp_file, "r") as f: 517 | file_content = f.read() 518 | assert file_content == original_content 519 | 520 | # Verify that selected lines are still available 521 | assert server.selected_start == 2 522 | assert server.selected_end == 4 523 | assert server.selected_id is not None 524 | assert server.pending_modified_lines is None 525 | assert server.pending_diff is None 526 | 527 | @pytest.mark.asyncio 528 | async def test_select_invalid_range(self, server, temp_file): 529 | """Test select with invalid line ranges.""" 530 | set_file_fn = self.get_tool_fn(server, "set_file") 531 | await set_file_fn(temp_file) 532 | select_fn = self.get_tool_fn(server, "select") 533 | result = await select_fn(start=0, end=2) 534 | assert "error" in result 535 | assert "start must be at least 1" in result["error"] 536 | result = await select_fn(start=1, end=10) 537 | assert "end" in result 538 | assert result["end"] == 5 539 | result = await select_fn(start=4, end=2) 540 | assert "error" in result 541 | assert "start cannot be greater than end" in result["error"] 542 | 543 | @pytest.mark.asyncio 544 | async def test_overwrite_id_verification_failed(self, server, temp_file): 545 | """Test overwrite with incorrect ID (content verification failure).""" 546 | set_file_fn = self.get_tool_fn(server, "set_file") 547 | await set_file_fn(temp_file) 548 | select_fn = self.get_tool_fn(server, "select") 549 | select_result = await select_fn(2, 3) 550 | with open(temp_file, "w") as f: 551 | f.write( 552 | "Modified Line 1\nModified Line 2\nModified Line 3\nModified Line 4\nModified Line 5\n" 553 | ) 554 | overwrite_fn = self.get_tool_fn(server, "overwrite") 555 | result = await overwrite_fn(new_lines={"lines": ["New content"]}) 556 | assert "error" in result 557 | assert "id verification failed" in result["error"] 558 | 559 | @pytest.mark.asyncio 560 | async def test_overwrite_different_line_count(self, server, temp_file): 561 | """Test overwrite with different line count (more or fewer lines).""" 562 | set_file_fn = self.get_tool_fn(server, "set_file") 563 | await set_file_fn(temp_file) 564 | select_fn = self.get_tool_fn(server, "select") 565 | select_result = await select_fn(2, 3) 566 | assert select_result["status"] == "success" 567 | overwrite_fn = self.get_tool_fn(server, "overwrite") 568 | new_lines = {"lines": ["New Line 2", "Extra Line", "New Line 3"]} 569 | result = await overwrite_fn(new_lines=new_lines) 570 | assert result["status"] == "preview" 571 | confirm_fn = self.get_tool_fn(server, "confirm") 572 | confirm_result = await confirm_fn() 573 | assert confirm_result["status"] == "success" 574 | with open(temp_file, "r") as f: 575 | file_content = f.read() 576 | expected_content = ( 577 | "Line 1\nNew Line 2\nExtra Line\nNew Line 3\nLine 4\nLine 5\n" 578 | ) 579 | assert file_content == expected_content 580 | select_result = await select_fn(1, 6) 581 | assert select_result["status"] == "success" 582 | new_content = "Single Line\n" 583 | result = await overwrite_fn(new_lines={"lines": ["Single Line"]}) 584 | assert result["status"] == "preview" 585 | confirm_result = await confirm_fn() 586 | assert confirm_result["status"] == "success" 587 | with open(temp_file, "r") as f: 588 | file_content = f.read() 589 | assert file_content == "Single Line\n" 590 | 591 | @pytest.mark.asyncio 592 | async def test_overwrite_empty_text(self, server, temp_file): 593 | """Test overwrite with empty text (effectively removing lines).""" 594 | set_file_fn = self.get_tool_fn(server, "set_file") 595 | await set_file_fn(temp_file) 596 | select_fn = self.get_tool_fn(server, "select") 597 | select_result = await select_fn(2, 3) 598 | assert select_result["status"] == "success" 599 | overwrite_fn = self.get_tool_fn(server, "overwrite") 600 | result = await overwrite_fn(new_lines={"lines": []}) 601 | assert result["status"] == "preview" 602 | confirm_fn = self.get_tool_fn(server, "confirm") 603 | confirm_result = await confirm_fn() 604 | assert confirm_result["status"] == "success" 605 | with open(temp_file, "r") as f: 606 | file_content = f.read() 607 | expected_content = "Line 1\nLine 4\nLine 5\n" 608 | assert file_content == expected_content 609 | 610 | @pytest.mark.asyncio 611 | async def test_select_max_lines_exceeded(self, server, temp_file): 612 | """Test select with a range exceeding max_select_lines.""" 613 | set_file_fn = self.get_tool_fn(server, "set_file") 614 | await set_file_fn(temp_file) 615 | more_than_max_lines = server.max_select_lines + 10 616 | with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: 617 | for i in range(more_than_max_lines): 618 | f.write(f"Line {i + 1}\n") 619 | large_file_path = f.name 620 | try: 621 | await set_file_fn(large_file_path) 622 | select_fn = self.get_tool_fn(server, "select") 623 | result = await select_fn(start=1, end=server.max_select_lines + 1) 624 | assert "error" in result 625 | assert ( 626 | f"Cannot select more than {server.max_select_lines} lines at once" 627 | in result["error"] 628 | ) 629 | finally: 630 | if os.path.exists(large_file_path): 631 | os.unlink(large_file_path) 632 | 633 | @pytest.mark.asyncio 634 | async def test_overwrite_file_read_error(self, server, temp_file, monkeypatch): 635 | """Test overwrite with file read error.""" 636 | set_file_fn = self.get_tool_fn(server, "set_file") 637 | await set_file_fn(temp_file) 638 | select_fn = self.get_tool_fn(server, "select") 639 | select_result = await select_fn(2, 3) 640 | assert select_result["status"] == "success" 641 | original_open = open 642 | 643 | def mock_open_read(*args, **kwargs): 644 | if args[1] == "r": 645 | raise IOError("Mock file read error") 646 | return original_open(*args, **kwargs) 647 | 648 | monkeypatch.setattr("builtins.open", mock_open_read) 649 | overwrite_fn = self.get_tool_fn(server, "overwrite") 650 | result = await overwrite_fn(new_lines={"lines": ["New content"]}) 651 | assert "error" in result 652 | assert "Error reading file" in result["error"] 653 | assert "Mock file read error" in result["error"] 654 | 655 | @pytest.mark.asyncio 656 | async def test_overwrite_file_write_error(self, server, temp_file, monkeypatch): 657 | """Test overwrite with file write error.""" 658 | set_file_fn = self.get_tool_fn(server, "set_file") 659 | await set_file_fn(temp_file) 660 | select_fn = self.get_tool_fn(server, "select") 661 | select_result = await select_fn(2, 3) 662 | assert select_result["status"] == "success" 663 | original_open = open 664 | open_calls = [0] 665 | 666 | def mock_open_write(*args, **kwargs): 667 | if args[1] == "w": 668 | raise IOError("Mock file write error") 669 | return original_open(*args, **kwargs) 670 | 671 | monkeypatch.setattr("builtins.open", mock_open_write) 672 | overwrite_fn = self.get_tool_fn(server, "overwrite") 673 | result = await overwrite_fn(new_lines={"lines": ["New content"]}) 674 | assert "status" in result 675 | assert result["status"] == "preview" 676 | confirm_fn = self.get_tool_fn(server, "confirm") 677 | confirm_result = await confirm_fn() 678 | assert "error" in confirm_result 679 | assert "Error writing to file" in confirm_result["error"] 680 | assert "Mock file write error" in confirm_result["error"] 681 | 682 | @pytest.mark.asyncio 683 | async def test_overwrite_newline_handling(self, server): 684 | """Test newline handling in overwrite (appends newline when needed).""" 685 | with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: 686 | f.write("Line 1\nLine 2\nLine 3") 687 | temp_path = f.name 688 | try: 689 | set_file_fn = self.get_tool_fn(server, "set_file") 690 | await set_file_fn(temp_path) 691 | select_fn = self.get_tool_fn(server, "select") 692 | select_result = await select_fn(2, 2) 693 | assert select_result["status"] == "success" 694 | overwrite_fn = self.get_tool_fn(server, "overwrite") 695 | result = await overwrite_fn(new_lines={"lines": ["New Line 2"]}) 696 | assert result["status"] == "preview" 697 | confirm_fn = self.get_tool_fn(server, "confirm") 698 | confirm_result = await confirm_fn() 699 | assert confirm_result["status"] == "success" 700 | with open(temp_path, "r") as f: 701 | file_content = f.read() 702 | expected_content = "Line 1\nNew Line 2\nLine 3" 703 | assert file_content == expected_content 704 | finally: 705 | if os.path.exists(temp_path): 706 | os.unlink(temp_path) 707 | 708 | @pytest.mark.asyncio 709 | async def test_overwrite_python_syntax_check_success(self, server): 710 | """Test Python syntax checking in overwrite succeeds with valid Python code.""" 711 | valid_python_content = ( 712 | "def hello():\n print('Hello, world!')\n\nresult = hello()\n" 713 | ) 714 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".py", delete=False) as f: 715 | f.write(valid_python_content) 716 | py_file_path = f.name 717 | try: 718 | set_file_fn = self.get_tool_fn(server, "set_file") 719 | await set_file_fn(py_file_path) 720 | select_fn = self.get_tool_fn(server, "select") 721 | select_result = await select_fn(1, 4) 722 | assert select_result["status"] == "success" 723 | overwrite_fn = self.get_tool_fn(server, "overwrite") 724 | new_content = { 725 | "lines": [ 726 | "def greeting(name):", 727 | " return f'Hello, {name}!'", 728 | "", 729 | "result = greeting('World')", 730 | ] 731 | } 732 | result = await overwrite_fn(new_lines=new_content) 733 | assert result["status"] == "preview" 734 | confirm_fn = self.get_tool_fn(server, "confirm") 735 | confirm_result = await confirm_fn() 736 | assert confirm_result["status"] == "success" 737 | assert "Changes applied successfully" in confirm_result["message"] 738 | with open(py_file_path, "r") as f: 739 | file_content = f.read() 740 | expected_content = "def greeting(name):\n return f'Hello, {name}!'\n\nresult = greeting('World')\n" 741 | assert file_content == expected_content 742 | finally: 743 | if os.path.exists(py_file_path): 744 | os.unlink(py_file_path) 745 | 746 | @pytest.mark.asyncio 747 | async def test_overwrite_python_syntax_check_failure(self, server): 748 | """Test Python syntax checking in overwrite fails with invalid Python code.""" 749 | valid_python_content = ( 750 | "def hello():\n print('Hello, world!')\n\nresult = hello()\n" 751 | ) 752 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".py", delete=False) as f: 753 | f.write(valid_python_content) 754 | py_file_path = f.name 755 | try: 756 | set_file_fn = self.get_tool_fn(server, "set_file") 757 | await set_file_fn(py_file_path) 758 | select_fn = self.get_tool_fn(server, "select") 759 | select_result = await select_fn(1, 4) 760 | assert select_result["status"] == "success" 761 | overwrite_fn = self.get_tool_fn(server, "overwrite") 762 | invalid_python = { 763 | "lines": [ 764 | "def broken_function(:", 765 | " print('Missing parenthesis'", 766 | "", 767 | "result = broken_function()", 768 | ] 769 | } 770 | result = await overwrite_fn(new_lines=invalid_python) 771 | assert "error" in result 772 | assert "Python syntax error:" in result["error"] 773 | with open(py_file_path, "r") as f: 774 | file_content = f.read() 775 | assert file_content == valid_python_content 776 | finally: 777 | if os.path.exists(py_file_path): 778 | os.unlink(py_file_path) 779 | 780 | @pytest.mark.asyncio 781 | async def test_overwrite_javascript_syntax_check_success(self, server, monkeypatch): 782 | """Test JavaScript syntax checking in overwrite succeeds with valid JS code.""" 783 | valid_js_content = "function hello() {\n return 'Hello, world!';\n}\n\nconst result = hello();\n" 784 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".js", delete=False) as f: 785 | f.write(valid_js_content) 786 | js_file_path = f.name 787 | 788 | def mock_subprocess_run(*args, **kwargs): 789 | class MockCompletedProcess: 790 | def __init__(self): 791 | self.returncode = 0 792 | self.stderr = "" 793 | self.stdout = "" 794 | 795 | return MockCompletedProcess() 796 | 797 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 798 | try: 799 | set_file_fn = self.get_tool_fn(server, "set_file") 800 | await set_file_fn(js_file_path) 801 | select_fn = self.get_tool_fn(server, "select") 802 | select_result = await select_fn(1, 5) 803 | assert select_result["status"] == "success" 804 | overwrite_fn = self.get_tool_fn(server, "overwrite") 805 | new_lines = { 806 | "lines": [ 807 | "function greeting(name) {", 808 | " return `Hello, ${name}!`;", 809 | "}", 810 | "", 811 | "const result = greeting('World');", 812 | ] 813 | } 814 | result = await overwrite_fn(new_lines=new_lines) 815 | assert result["status"] == "preview" 816 | confirm_fn = self.get_tool_fn(server, "confirm") 817 | confirm_result = await confirm_fn() 818 | assert confirm_result["status"] == "success" 819 | assert "Changes applied successfully" in confirm_result["message"] 820 | with open(js_file_path, "r") as f: 821 | file_content = f.read() 822 | expected_content = "function greeting(name) {\n return `Hello, ${name}!`;\n}\n\nconst result = greeting('World');\n" 823 | assert file_content == expected_content 824 | finally: 825 | if os.path.exists(js_file_path): 826 | os.unlink(js_file_path) 827 | 828 | @pytest.mark.asyncio 829 | async def test_overwrite_javascript_syntax_check_failure(self, server, monkeypatch): 830 | """Test JavaScript syntax checking in overwrite fails with invalid JS code.""" 831 | valid_js_content = "function hello() {\n return 'Hello, world!';\n}\n\nconst result = hello();\n" 832 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".js", delete=False) as f: 833 | f.write(valid_js_content) 834 | js_file_path = f.name 835 | 836 | def mock_subprocess_run(*args, **kwargs): 837 | class MockCompletedProcess: 838 | def __init__(self): 839 | self.returncode = 1 840 | self.stderr = "SyntaxError: Unexpected token (1:19)" 841 | self.stdout = "" 842 | 843 | return MockCompletedProcess() 844 | 845 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 846 | try: 847 | set_file_fn = self.get_tool_fn(server, "set_file") 848 | await set_file_fn(js_file_path) 849 | select_fn = self.get_tool_fn(server, "select") 850 | select_result = await select_fn(1, 5) 851 | overwrite_fn = self.get_tool_fn(server, "overwrite") 852 | invalid_js = { 853 | "lines": [ 854 | "function broken() {", 855 | " return 'Missing closing bracket;", 856 | "}", 857 | "", 858 | "const result = broken();", 859 | ] 860 | } 861 | result = await overwrite_fn(new_lines=invalid_js) 862 | assert "error" in result 863 | assert "JavaScript syntax error:" in result["error"] 864 | with open(js_file_path, "r") as f: 865 | file_content = f.read() 866 | assert file_content == valid_js_content 867 | finally: 868 | if os.path.exists(js_file_path): 869 | os.unlink(js_file_path) 870 | 871 | @pytest.mark.asyncio 872 | async def test_overwrite_jsx_syntax_check_success(self, server, monkeypatch): 873 | """Test JSX syntax checking in overwrite succeeds with valid React/JSX code.""" 874 | valid_jsx_content = "import React from 'react';\n\nfunction HelloWorld() {\n return
Hello, world!
;\n}\n\nexport default HelloWorld;\n" 875 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".jsx", delete=False) as f: 876 | f.write(valid_jsx_content) 877 | jsx_file_path = f.name 878 | 879 | def mock_subprocess_run(*args, **kwargs): 880 | class MockCompletedProcess: 881 | def __init__(self): 882 | self.returncode = 0 883 | self.stderr = "" 884 | self.stdout = "" 885 | 886 | return MockCompletedProcess() 887 | 888 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 889 | try: 890 | set_file_fn = self.get_tool_fn(server, "set_file") 891 | await set_file_fn(jsx_file_path) 892 | select_fn = self.get_tool_fn(server, "select") 893 | select_result = await select_fn(1, 7) 894 | assert select_result["status"] == "success" 895 | overwrite_fn = self.get_tool_fn(server, "overwrite") 896 | new_jsx_content = { 897 | "lines": [ 898 | "import React from 'react';", 899 | "", 900 | "function Greeting({ name }) {", 901 | " return
Hello, {name}!
;", 902 | "}", 903 | "", 904 | "export default Greeting;", 905 | ] 906 | } 907 | result = await overwrite_fn(new_lines=new_jsx_content) 908 | assert result["status"] == "preview" 909 | confirm_fn = self.get_tool_fn(server, "confirm") 910 | confirm_result = await confirm_fn() 911 | assert confirm_result["status"] == "success" 912 | assert "Changes applied successfully" in confirm_result["message"] 913 | with open(jsx_file_path, "r") as f: 914 | file_content = f.read() 915 | expected_content = "import React from 'react';\n\nfunction Greeting({ name }) {\n return
Hello, {name}!
;\n}\n\nexport default Greeting;\n" 916 | assert file_content == expected_content 917 | finally: 918 | if os.path.exists(jsx_file_path): 919 | os.unlink(jsx_file_path) 920 | 921 | @pytest.mark.asyncio 922 | async def test_overwrite_jsx_syntax_check_failure(self, server, monkeypatch): 923 | """Test JSX syntax checking in overwrite fails with invalid React/JSX code.""" 924 | valid_jsx_content = "import React from 'react';\n\nfunction HelloWorld() {\n return
Hello, world!
;\n}\n\nexport default HelloWorld;\n" 925 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".jsx", delete=False) as f: 926 | f.write(valid_jsx_content) 927 | jsx_file_path = f.name 928 | 929 | def mock_subprocess_run(*args, **kwargs): 930 | class MockCompletedProcess: 931 | def __init__(self): 932 | self.returncode = 1 933 | self.stderr = "SyntaxError: Unexpected token (4:10)" 934 | self.stdout = "" 935 | 936 | return MockCompletedProcess() 937 | 938 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 939 | try: 940 | set_file_fn = self.get_tool_fn(server, "set_file") 941 | await set_file_fn(jsx_file_path) 942 | select_fn = self.get_tool_fn(server, "select") 943 | select_result = await select_fn(1, 7) 944 | overwrite_fn = self.get_tool_fn(server, "overwrite") 945 | invalid_jsx = { 946 | "lines": [ 947 | "import React from 'react';", 948 | "", 949 | "function BrokenComponent() {", 950 | " return
Missing closing tag
;", 951 | "}", 952 | "", 953 | "export default BrokenComponent;", 954 | ] 955 | } 956 | result = await overwrite_fn(new_lines=invalid_jsx) 957 | assert "error" in result 958 | assert "JavaScript syntax error:" in result["error"] 959 | with open(jsx_file_path, "r") as f: 960 | file_content = f.read() 961 | assert file_content == valid_jsx_content 962 | finally: 963 | if os.path.exists(jsx_file_path): 964 | os.unlink(jsx_file_path) 965 | 966 | @pytest.mark.asyncio 967 | async def test_generate_diff_preview(self): 968 | """Test the generate_diff_preview function directly.""" 969 | original_lines = ["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"] 970 | modified_lines = [ 971 | "Line 1", 972 | "Modified Line 2", 973 | "New Line", 974 | "Line 3", 975 | "Line 4", 976 | "Line 5", 977 | ] 978 | 979 | # Testing replacement in the middle of the file 980 | result = generate_diff_preview(original_lines, modified_lines, 2, 3) 981 | 982 | # Verify the result contains the expected diff_lines key 983 | assert "diff_lines" in result 984 | 985 | # Get and examine the content of diff_lines 986 | diff_lines_list = result["diff_lines"] 987 | 988 | # The diff_lines should be a list of tuples, let's check its structure 989 | # First verify we have the expected number of elements 990 | assert len(diff_lines_list) > 0 991 | 992 | # Check that we have context lines before the change 993 | # The first element should be the context line with line number 1 994 | assert any(item for item in diff_lines_list if item[0] == 1) 995 | 996 | # Check for removed lines with minus prefix 997 | assert any(item for item in diff_lines_list if item[0] == "-2") 998 | assert any(item for item in diff_lines_list if item[0] == "-3") 999 | 1000 | # Check for added lines with plus prefix 1001 | # There should be one entry containing the modified content 1002 | added_lines = [ 1003 | item 1004 | for item in diff_lines_list 1005 | if isinstance(item[0], str) and item[0].startswith("+") 1006 | ] 1007 | assert len(added_lines) > 0 1008 | 1009 | # Verify context after the change (line 4 and 5) 1010 | assert any(item for item in diff_lines_list if item[0] == 4) 1011 | assert any(item for item in diff_lines_list if item[0] == 5) 1012 | 1013 | @pytest.fixture 1014 | def python_test_file(self): 1015 | """Create a Python test file with various functions and methods for testing find_function.""" 1016 | content = '''import os 1017 | 1018 | def simple_function(): 1019 | """A simple function.""" 1020 | return "Hello, world!" 1021 | 1022 | @decorator1 1023 | @decorator2 1024 | def decorated_function(a, b=None): 1025 | """A function with decorators.""" 1026 | if b is None: 1027 | b = a * 2 1028 | return a + b 1029 | 1030 | class TestClass: 1031 | """A test class with methods.""" 1032 | 1033 | def __init__(self, value): 1034 | self.value = value 1035 | 1036 | def instance_method(self, x): 1037 | """An instance method.""" 1038 | return self.value * x 1039 | 1040 | @classmethod 1041 | def class_method(cls, y): 1042 | """A class method.""" 1043 | return cls(y) 1044 | 1045 | @staticmethod 1046 | def static_method(z): 1047 | """A static method.""" 1048 | return z ** 2 1049 | 1050 | def outer_function(param): 1051 | """A function containing a nested function.""" 1052 | 1053 | def inner_function(inner_param): 1054 | """A nested function.""" 1055 | return inner_param + param 1056 | 1057 | return inner_function(param * 2) 1058 | ''' 1059 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".py", delete=False) as f: 1060 | f.write(content) 1061 | temp_path = f.name 1062 | yield temp_path 1063 | if os.path.exists(temp_path): 1064 | os.unlink(temp_path) 1065 | 1066 | @pytest.mark.asyncio 1067 | async def test_find_function_no_file_set(self, server): 1068 | """Test find_function when no file is set.""" 1069 | find_function_fn = self.get_tool_fn(server, "find_function") 1070 | result = await find_function_fn(function_name="test") 1071 | assert "error" in result 1072 | assert "No file path is set" in result["error"] 1073 | 1074 | @pytest.mark.asyncio 1075 | async def test_find_function_non_supported_file(self, server, temp_file): 1076 | """Test find_function with a non-supported file type.""" 1077 | set_file_fn = self.get_tool_fn(server, "set_file") 1078 | await set_file_fn(temp_file) 1079 | find_function_fn = self.get_tool_fn(server, "find_function") 1080 | result = await find_function_fn(function_name="test") 1081 | assert "error" in result 1082 | assert ( 1083 | "This tool only works with Python (.py) or JavaScript/JSX (.js, .jsx) files" 1084 | in result["error"] 1085 | ) 1086 | 1087 | @pytest.mark.asyncio 1088 | async def test_find_function_simple(self, server, python_test_file): 1089 | """Test find_function with a simple function.""" 1090 | set_file_fn = self.get_tool_fn(server, "set_file") 1091 | await set_file_fn(python_test_file) 1092 | find_function_fn = self.get_tool_fn(server, "find_function") 1093 | result = await find_function_fn(function_name="simple_function") 1094 | assert "status" in result 1095 | assert result["status"] == "success" 1096 | assert "lines" in result 1097 | assert "start_line" in result 1098 | assert "end_line" in result 1099 | 1100 | # Check that the correct function is returned 1101 | function_text = "".join(line[1] for line in result["lines"]) 1102 | assert "def simple_function():" in function_text 1103 | assert "A simple function" in function_text 1104 | assert 'return "Hello, world!"' in function_text 1105 | 1106 | @pytest.mark.asyncio 1107 | async def test_find_function_decorated(self, server, python_test_file): 1108 | """Test find_function with a decorated function.""" 1109 | set_file_fn = self.get_tool_fn(server, "set_file") 1110 | await set_file_fn(python_test_file) 1111 | find_function_fn = self.get_tool_fn(server, "find_function") 1112 | result = await find_function_fn(function_name="decorated_function") 1113 | assert result["status"] == "success" 1114 | 1115 | # Check that the decorators are included 1116 | function_lines = [line[1] for line in result["lines"]] 1117 | assert any("@decorator1" in line for line in function_lines) 1118 | assert any("@decorator2" in line for line in function_lines) 1119 | assert any( 1120 | "def decorated_function(a, b=None):" in line for line in function_lines 1121 | ) 1122 | 1123 | @pytest.mark.asyncio 1124 | async def test_find_function_method(self, server, python_test_file): 1125 | """Test find_function with a class method.""" 1126 | set_file_fn = self.get_tool_fn(server, "set_file") 1127 | await set_file_fn(python_test_file) 1128 | find_function_fn = self.get_tool_fn(server, "find_function") 1129 | result = await find_function_fn(function_name="instance_method") 1130 | assert result["status"] == "success" 1131 | 1132 | # Check that the method is correctly identified 1133 | function_text = "".join(line[1] for line in result["lines"]) 1134 | assert "def instance_method(self, x):" in function_text 1135 | assert "An instance method" in function_text 1136 | assert "return self.value * x" in function_text 1137 | 1138 | @pytest.mark.asyncio 1139 | async def test_find_function_static_method(self, server, python_test_file): 1140 | """Test find_function with a static method.""" 1141 | set_file_fn = self.get_tool_fn(server, "set_file") 1142 | await set_file_fn(python_test_file) 1143 | find_function_fn = self.get_tool_fn(server, "find_function") 1144 | result = await find_function_fn(function_name="static_method") 1145 | assert result["status"] == "success" 1146 | 1147 | # Check that the decorator and method are included 1148 | function_lines = [line[1] for line in result["lines"]] 1149 | assert any("@staticmethod" in line for line in function_lines) 1150 | assert any("def static_method(z):" in line for line in function_lines) 1151 | 1152 | @pytest.mark.asyncio 1153 | async def test_find_function_not_found(self, server, python_test_file): 1154 | """Test find_function with a non-existent function.""" 1155 | set_file_fn = self.get_tool_fn(server, "set_file") 1156 | await set_file_fn(python_test_file) 1157 | find_function_fn = self.get_tool_fn(server, "find_function") 1158 | result = await find_function_fn(function_name="nonexistent_function") 1159 | assert "error" in result 1160 | assert "not found in the file" in result["error"] 1161 | 1162 | @pytest.mark.asyncio 1163 | async def test_protect_paths_env_variable(self, monkeypatch): 1164 | """Test that the PROTECTED_PATHS environment variable is correctly processed.""" 1165 | # Set up the environment variable with test paths 1166 | monkeypatch.setenv( 1167 | "PROTECTED_PATHS", 1168 | "*.secret,.env*,config*.json,*sensitive*,/etc/shadow,/home/user/.ssh/id_rsa", 1169 | ) 1170 | 1171 | # Create a new server instance which should read the environment variable 1172 | server = TextEditorServer() 1173 | 1174 | # Verify the protected_paths list is populated correctly 1175 | assert len(server.protected_paths) == 6 1176 | assert "*.secret" in server.protected_paths 1177 | assert ".env*" in server.protected_paths 1178 | assert "config*.json" in server.protected_paths 1179 | assert "*sensitive*" in server.protected_paths 1180 | assert "/etc/shadow" in server.protected_paths 1181 | assert "/home/user/.ssh/id_rsa" in server.protected_paths 1182 | 1183 | @pytest.mark.asyncio 1184 | async def test_protect_paths_empty_env_variable(self, monkeypatch): 1185 | """Test that an empty PROTECTED_PATHS environment variable is handled correctly.""" 1186 | # Set up an empty environment variable 1187 | monkeypatch.setenv("PROTECTED_PATHS", "") 1188 | 1189 | # Create a new server instance 1190 | server = TextEditorServer() 1191 | 1192 | # Verify the protected_paths list is empty 1193 | assert len(server.protected_paths) == 0 1194 | 1195 | @pytest.mark.asyncio 1196 | async def test_protect_paths_trimming(self, monkeypatch): 1197 | """Test that whitespace in PROTECTED_PATHS items is properly trimmed.""" 1198 | # Set up the environment variable with whitespace 1199 | monkeypatch.setenv( 1200 | "PROTECTED_PATHS", " *.secret , /etc/shadow , /home/user/.ssh/id_rsa " 1201 | ) 1202 | 1203 | # Create a new server instance 1204 | server = TextEditorServer() 1205 | 1206 | # Get set_file tool for testing 1207 | set_file_fn = self.get_tool_fn(server, "set_file") 1208 | 1209 | # Mock os.path.isfile to return True for our test path 1210 | def mock_isfile(path): 1211 | return True 1212 | 1213 | monkeypatch.setattr(os.path, "isfile", mock_isfile) 1214 | 1215 | # Test access denied for a path matching a trimmed pattern 1216 | result = await set_file_fn("/home/user/.ssh/id_rsa") 1217 | assert "Error: Access to '/home/user/.ssh/id_rsa' is denied" in result 1218 | 1219 | @pytest.mark.asyncio 1220 | async def test_find_function_nested(self, server, python_test_file): 1221 | """Test find_function with nested functions.""" 1222 | set_file_fn = self.get_tool_fn(server, "set_file") 1223 | await set_file_fn(python_test_file) 1224 | find_function_fn = self.get_tool_fn(server, "find_function") 1225 | 1226 | # Test finding the outer function 1227 | result = await find_function_fn(function_name="outer_function") 1228 | assert result["status"] == "success" 1229 | function_text = "".join(line[1] for line in result["lines"]) 1230 | assert "def outer_function(param):" in function_text 1231 | assert "def inner_function(inner_param):" in function_text 1232 | 1233 | # Test finding the inner function (this may or may not work depending on implementation) 1234 | # AST might not directly support finding nested functions 1235 | # This test is designed to document current behavior, not necessarily assert correctness 1236 | inner_result = await find_function_fn(function_name="inner_function") 1237 | # If it finds the inner function, check it's correct 1238 | if "status" in inner_result and inner_result["status"] == "success": 1239 | inner_text = "".join(line[1] for line in inner_result["lines"]) 1240 | assert "def inner_function(inner_param):" in inner_text 1241 | # Otherwise, it should return an error that the function wasn't found 1242 | else: 1243 | assert "error" in inner_result 1244 | assert "not found in the file" in inner_result["error"] 1245 | 1246 | @pytest.mark.asyncio 1247 | async def test_find_function_parsing_error(self, server): 1248 | """Test find_function with a file that can't be parsed due to syntax errors.""" 1249 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".py", delete=False) as f: 1250 | f.write( 1251 | "def broken_function( # Syntax error: missing parenthesis\n pass\n" 1252 | ) 1253 | invalid_py_path = f.name 1254 | try: 1255 | set_file_fn = self.get_tool_fn(server, "set_file") 1256 | await set_file_fn(invalid_py_path) 1257 | find_function_fn = self.get_tool_fn(server, "find_function") 1258 | result = await find_function_fn(function_name="broken_function") 1259 | assert "error" in result 1260 | assert "Error finding function" in result["error"] 1261 | finally: 1262 | if os.path.exists(invalid_py_path): 1263 | os.unlink(invalid_py_path) 1264 | 1265 | @pytest.mark.asyncio 1266 | async def test_find_function_javascript( 1267 | self, server, javascript_test_file, monkeypatch 1268 | ): 1269 | """Test find_function with JavaScript functions.""" 1270 | 1271 | # Mock subprocess.run to avoid external dependency in tests 1272 | def mock_subprocess_run(*args, **kwargs): 1273 | class MockCompletedProcess: 1274 | def __init__(self): 1275 | self.returncode = 0 1276 | self.stderr = "" 1277 | self.stdout = "" 1278 | 1279 | return MockCompletedProcess() 1280 | 1281 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 1282 | 1283 | set_file_fn = self.get_tool_fn(server, "set_file") 1284 | await set_file_fn(javascript_test_file) 1285 | find_function_fn = self.get_tool_fn(server, "find_function") 1286 | 1287 | # Test regular function 1288 | result = await find_function_fn(function_name="simpleFunction") 1289 | assert result["status"] == "success" 1290 | # Get all the lines of the function 1291 | function_lines = [line[1] for line in result["lines"]] 1292 | assert any("function simpleFunction()" in line for line in function_lines) 1293 | assert any("console.log('Hello world')" in line for line in function_lines) 1294 | 1295 | # Test arrow function 1296 | result = await find_function_fn(function_name="arrowFunction") 1297 | assert result["status"] == "success" 1298 | function_lines = [line[1] for line in result["lines"]] 1299 | assert any("const arrowFunction = (a, b) =>" in line for line in function_lines) 1300 | 1301 | # Test async function 1302 | result = await find_function_fn(function_name="asyncFunction") 1303 | assert result["status"] == "success" 1304 | function_lines = [line[1] for line in result["lines"]] 1305 | assert any("async function asyncFunction()" in line for line in function_lines) 1306 | 1307 | # Test hook-style function 1308 | result = await find_function_fn(function_name="useCustomHook") 1309 | assert result["status"] == "success" 1310 | function_lines = [line[1] for line in result["lines"]] 1311 | assert any( 1312 | "const useCustomHook = useCallback" in line for line in function_lines 1313 | ) 1314 | 1315 | result = await find_function_fn(function_name="methodFunction") 1316 | 1317 | function_lines = [line[1] for line in result["lines"]] 1318 | assert any("methodFunction(x, y)" in line for line in function_lines) 1319 | 1320 | result = await find_function_fn(function_name="nonExistentFunction") 1321 | assert "error" in result 1322 | assert "not found in the file" in result["error"] 1323 | 1324 | @pytest.mark.asyncio 1325 | async def test_find_function_jsx(self, server, jsx_test_file, monkeypatch): 1326 | """Test find_function with JSX/React component functions.""" 1327 | 1328 | def mock_subprocess_run(*args, **kwargs): 1329 | class MockCompletedProcess: 1330 | def __init__(self): 1331 | self.returncode = 0 1332 | self.stderr = "" 1333 | self.stdout = "" 1334 | 1335 | return MockCompletedProcess() 1336 | 1337 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 1338 | 1339 | set_file_fn = self.get_tool_fn(server, "set_file") 1340 | await set_file_fn(jsx_test_file) 1341 | find_function_fn = self.get_tool_fn(server, "find_function") 1342 | 1343 | # Test regular function component 1344 | result = await find_function_fn(function_name="SimpleComponent") 1345 | assert result["status"] == "success" 1346 | # Get all the lines of the component 1347 | function_lines = [line[1] for line in result["lines"]] 1348 | # Just check that the function name is found 1349 | assert "SimpleComponent" in "".join(function_lines) 1350 | 1351 | # Test arrow function component 1352 | result = await find_function_fn(function_name="ArrowComponent") 1353 | assert result["status"] == "success" 1354 | # Get all the lines of the component 1355 | function_lines = [line[1] for line in result["lines"]] 1356 | # Only check that we found the function declaration 1357 | assert "ArrowComponent" in "".join(function_lines) 1358 | 1359 | # Test component with nested function 1360 | result = await find_function_fn(function_name="ParentComponent") 1361 | assert result["status"] == "success" 1362 | # Get all the lines of the component 1363 | function_lines = [line[1] for line in result["lines"]] 1364 | assert any("function ParentComponent()" in line for line in function_lines) 1365 | assert any("function handleClick()" in line for line in function_lines) 1366 | 1367 | # Test higher order component 1368 | result = await find_function_fn(function_name="withLogger") 1369 | assert result["status"] == "success" 1370 | # Get all the lines of the component 1371 | function_lines = [line[1] for line in result["lines"]] 1372 | assert any("function withLogger(Component)" in line for line in function_lines) 1373 | assert any( 1374 | "return function EnhancedComponent(props)" in line 1375 | for line in function_lines 1376 | ) 1377 | 1378 | # Test nested function may or may not work depending on implementation 1379 | result = await find_function_fn(function_name="handleClick") 1380 | if "status" in result and result["status"] == "success": 1381 | function_lines = [line[1] for line in result["lines"]] 1382 | assert any("function handleClick()" in line for line in function_lines) 1383 | else: 1384 | assert "error" in result 1385 | 1386 | # Test non-existent function 1387 | result = await find_function_fn(function_name="nonExistentComponent") 1388 | assert "error" in result 1389 | assert "not found in the file" in result["error"] 1390 | 1391 | @pytest.mark.asyncio 1392 | async def test_find_function_js_with_disabled_check(self, server, monkeypatch): 1393 | """Test find_function with disabled JavaScript syntax checking.""" 1394 | # Create a server with disabled JS syntax checking 1395 | monkeypatch.setenv("ENABLE_JS_SYNTAX_CHECK", "0") 1396 | server_no_js_check = TextEditorServer() 1397 | 1398 | # Create a basic JavaScript file 1399 | js_content = "function testFunc() { return 'test'; }" 1400 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".js", delete=False) as f: 1401 | f.write(js_content) 1402 | js_file_path = f.name 1403 | 1404 | try: 1405 | # This test ensures find_function still works even when JavaScript 1406 | # syntax checking is disabled for overwrite operations 1407 | set_file_fn = self.get_tool_fn(server_no_js_check, "set_file") 1408 | await set_file_fn(js_file_path) 1409 | find_function_fn = self.get_tool_fn(server_no_js_check, "find_function") 1410 | 1411 | result = await find_function_fn(function_name="testFunc") 1412 | assert result["status"] == "success" 1413 | function_lines = [line[1] for line in result["lines"]] 1414 | assert any("function testFunc()" in line for line in function_lines) 1415 | finally: 1416 | if os.path.exists(js_file_path): 1417 | os.unlink(js_file_path) 1418 | 1419 | @pytest.mark.asyncio 1420 | async def test_listdir_tool(self, server, temp_file, monkeypatch): 1421 | """Test the listdir tool functionality.""" 1422 | # Create a test directory 1423 | with tempfile.TemporaryDirectory() as temp_dir: 1424 | # Create some test files in the directory 1425 | test_files = ["file1.txt", "file2.py", "file3.js"] 1426 | for file_name in test_files: 1427 | file_path = os.path.join(temp_dir, file_name) 1428 | with open(file_path, "w") as f: 1429 | f.write(f"Content for {file_name}") 1430 | 1431 | # Test the listdir tool 1432 | listdir_fn = self.get_tool_fn(server, "listdir") 1433 | result = await listdir_fn(dirpath=temp_dir) 1434 | 1435 | # Check that the result contains the expected data 1436 | assert "filenames" in result 1437 | assert "path" in result 1438 | assert result["path"] == temp_dir 1439 | 1440 | # Check that all test files are in the result 1441 | for file_name in test_files: 1442 | assert file_name in result["filenames"] 1443 | 1444 | @pytest.mark.asyncio 1445 | async def test_listdir_error_not_directory(self, server, temp_file): 1446 | """Test the listdir tool with a path that is not a directory.""" 1447 | # Use the temp_file fixture which is a file, not a directory 1448 | listdir_fn = self.get_tool_fn(server, "listdir") 1449 | result = await listdir_fn(dirpath=temp_file) 1450 | 1451 | # Check that an appropriate error is returned 1452 | assert "error" in result 1453 | assert "not a directory" in result["error"].lower() 1454 | assert "path" in result 1455 | assert result["path"] == temp_file 1456 | 1457 | @pytest.mark.asyncio 1458 | async def test_listdir_error_nonexistent_path(self, server): 1459 | """Test the listdir tool with a non-existent path.""" 1460 | # Create a path that doesn't exist 1461 | nonexistent_path = "/path/that/does/not/exist" 1462 | 1463 | # Test the listdir tool with the non-existent path 1464 | listdir_fn = self.get_tool_fn(server, "listdir") 1465 | result = await listdir_fn(dirpath=nonexistent_path) 1466 | 1467 | # Check that an appropriate error is returned 1468 | assert "error" in result 1469 | assert "unexpected error" in result["error"].lower() 1470 | 1471 | @pytest.mark.asyncio 1472 | async def test_duckdb_usage_stats_enabled(self, monkeypatch): 1473 | """Test that usage stats are enabled when environment variable is set.""" 1474 | # Mock environment variables to enable stats 1475 | monkeypatch.setenv("DUCKDB_USAGE_STATS", "1") 1476 | monkeypatch.setenv("STATS_DB_PATH", ":memory:") 1477 | 1478 | # Create a server with usage stats enabled 1479 | server = TextEditorServer() 1480 | 1481 | # Check that usage stats are enabled 1482 | assert server.usage_stats_enabled is True 1483 | assert server.stats_db_path == ":memory:" 1484 | 1485 | # Verify the decorator wrapper was applied 1486 | assert server.mcp.tool != FastMCP.tool 1487 | 1488 | @pytest.mark.asyncio 1489 | async def test_duckdb_usage_stats_disabled(self, monkeypatch): 1490 | """Test that usage stats are disabled by default.""" 1491 | # Mock environment variables to explicitly disable stats 1492 | monkeypatch.setenv("DUCKDB_USAGE_STATS", "0") 1493 | 1494 | # Create a server with usage stats disabled 1495 | server = TextEditorServer() 1496 | 1497 | # Check that usage stats are disabled 1498 | assert server.usage_stats_enabled is False 1499 | assert not hasattr(server, "stats_db_path") 1500 | 1501 | @pytest.mark.asyncio 1502 | async def test_find_js_function_babel( 1503 | self, server, javascript_test_file, monkeypatch 1504 | ): 1505 | """Test the _find_js_function_babel method for JavaScript parsing.""" 1506 | 1507 | # Create a mock subprocess result with Babel output 1508 | class MockCompletedProcess: 1509 | def __init__(self): 1510 | self.returncode = 0 1511 | # Mock Babel output with function location data 1512 | self.stdout = """FUNCTION_LOCATIONS: { 1513 | "simpleFunction": { 1514 | "start": {"line": 5, "column": 0}, 1515 | "end": {"line": 8, "column": 1} 1516 | } 1517 | }""" 1518 | self.stderr = "" 1519 | 1520 | # Mock subprocess.run to return our mock data 1521 | monkeypatch.setattr( 1522 | "subprocess.run", lambda *args, **kwargs: MockCompletedProcess() 1523 | ) 1524 | 1525 | # Set up the server with the test file 1526 | set_file_fn = self.get_tool_fn(server, "set_file") 1527 | await set_file_fn(javascript_test_file) 1528 | 1529 | # Call _find_js_function_babel directly 1530 | result = server._find_js_function_babel( 1531 | "simpleFunction", 1532 | "function simpleFunction() {}\n", 1533 | ["function simpleFunction() {}\n"], 1534 | ) 1535 | 1536 | # Verify the result 1537 | assert result is not None 1538 | assert "status" in result 1539 | assert result["status"] == "success" 1540 | assert "parser" in result 1541 | assert result["parser"] == "babel" 1542 | assert "start_line" in result 1543 | assert result["start_line"] == 5 1544 | assert "end_line" in result 1545 | assert result["end_line"] == 8 1546 | 1547 | @pytest.mark.asyncio 1548 | async def test_find_js_function_babel_no_match( 1549 | self, server, javascript_test_file, monkeypatch 1550 | ): 1551 | """Test _find_js_function_babel when no matching function is found.""" 1552 | 1553 | # Create a mock subprocess result with Babel output for a different function 1554 | class MockCompletedProcess: 1555 | def __init__(self): 1556 | self.returncode = 0 1557 | # Mock Babel output with function location data 1558 | self.stdout = """FUNCTION_LOCATIONS: { 1559 | "otherFunction": { 1560 | "start": {"line": 10, "column": 0}, 1561 | "end": {"line": 12, "column": 1} 1562 | } 1563 | }""" 1564 | self.stderr = "" 1565 | 1566 | # Mock subprocess.run to return our mock data 1567 | monkeypatch.setattr( 1568 | "subprocess.run", lambda *args, **kwargs: MockCompletedProcess() 1569 | ) 1570 | 1571 | # Set up the server with the test file 1572 | set_file_fn = self.get_tool_fn(server, "set_file") 1573 | await set_file_fn(javascript_test_file) 1574 | 1575 | # Call _find_js_function_babel for a function that doesn't exist in the mock output 1576 | result = server._find_js_function_babel( 1577 | "simpleFunction", 1578 | "function simpleFunction() {}\n", 1579 | ["function simpleFunction() {}\n"], 1580 | ) 1581 | 1582 | # Should return None when the function is not found 1583 | assert result is None 1584 | 1585 | @pytest.fixture 1586 | def javascript_test_file(self): 1587 | """Create a JavaScript test file with various functions for testing find_function.""" 1588 | content = """// Sample JavaScript file with different function types 1589 | 1590 | // Regular function declaration 1591 | function simpleFunction() { 1592 | console.log('Hello world'); 1593 | return 42; 1594 | } 1595 | 1596 | // Arrow function expression 1597 | const arrowFunction = (a, b) => { 1598 | const sum = a + b; 1599 | return sum; 1600 | }; 1601 | 1602 | // Object with method 1603 | const obj = { 1604 | methodFunction(x, y) { 1605 | return x * y; 1606 | }, 1607 | 1608 | // Object method as arrow function 1609 | arrowMethod: (z) => { 1610 | return z * z; 1611 | } 1612 | }; 1613 | 1614 | // Async function 1615 | async function asyncFunction() { 1616 | return await Promise.resolve('done'); 1617 | } 1618 | 1619 | // React hook style function 1620 | const useCustomHook = useCallback((value) => { 1621 | return value.toUpperCase(); 1622 | }, []); 1623 | 1624 | // Class with methods 1625 | class TestClass { 1626 | constructor(value) { 1627 | this.value = value; 1628 | } 1629 | 1630 | instanceMethod() { 1631 | return this.value; 1632 | } 1633 | 1634 | static staticMethod() { 1635 | return 'static'; 1636 | } 1637 | } 1638 | """ 1639 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".js", delete=False) as f: 1640 | f.write(content) 1641 | temp_path = f.name 1642 | yield temp_path 1643 | if os.path.exists(temp_path): 1644 | os.unlink(temp_path) 1645 | 1646 | @pytest.fixture 1647 | def jsx_test_file(self): 1648 | """Create a JSX test file with various component functions for testing find_function.""" 1649 | content = """import React, { useState, useEffect } from 'react'; 1650 | 1651 | // Function component 1652 | function SimpleComponent() { 1653 | return
Hello World
; 1654 | } 1655 | 1656 | // Arrow function component with props 1657 | const ArrowComponent = ({ name }) => { 1658 | const [count, setCount] = useState(0); 1659 | 1660 | useEffect(() => { 1661 | document.title = `${name}: ${count}`; 1662 | }, [name, count]); 1663 | 1664 | return ( 1665 |
1666 |

Hello {name}

1667 | 1670 |
1671 | ); 1672 | }; 1673 | 1674 | // Component with nested function 1675 | function ParentComponent() { 1676 | function handleClick() { 1677 | console.log('Button clicked'); 1678 | } 1679 | 1680 | return ; 1681 | } 1682 | 1683 | // Higher order component 1684 | function withLogger(Component) { 1685 | return function EnhancedComponent(props) { 1686 | console.log('Component rendered with props:', props); 1687 | return ; 1688 | }; 1689 | } 1690 | 1691 | export default SimpleComponent; 1692 | """ 1693 | with tempfile.NamedTemporaryFile(mode="w+", suffix=".jsx", delete=False) as f: 1694 | f.write(content) 1695 | temp_path = f.name 1696 | yield temp_path 1697 | if os.path.exists(temp_path): 1698 | os.unlink(temp_path) 1699 | 1700 | @pytest.mark.asyncio 1701 | async def test_run_tests_basic(self, server, monkeypatch): 1702 | """Test the basic functionality of run_tests.""" 1703 | 1704 | # Mock subprocess.run to simulate pytest execution 1705 | def mock_subprocess_run(*args, **kwargs): 1706 | class MockCompletedProcess: 1707 | def __init__(self): 1708 | self.returncode = 0 1709 | self.stdout = "===== 5 passed in 0.12s =====" 1710 | self.stderr = "" 1711 | 1712 | return MockCompletedProcess() 1713 | 1714 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 1715 | monkeypatch.setattr("datetime.datetime", MockDateTime) 1716 | 1717 | # Run the test function 1718 | run_tests_fn = self.get_tool_fn(server, "run_tests") 1719 | result = await run_tests_fn() 1720 | print("Result:", result) 1721 | 1722 | # Verify the result 1723 | assert result["status"] == "success" 1724 | assert result["returncode"] == 0 1725 | assert "5 passed" in result["stdout"] 1726 | assert result["duration"] == 0.5 # From our mock 1727 | assert "python -m pytest" in result["command"] 1728 | 1729 | @pytest.mark.asyncio 1730 | async def test_run_tests_with_path(self, server, monkeypatch): 1731 | """Test run_tests with a specific test path.""" 1732 | expected_cmd = "" 1733 | 1734 | def mock_subprocess_run(*args, **kwargs): 1735 | nonlocal expected_cmd 1736 | expected_cmd = " ".join(args[0]) 1737 | 1738 | class MockCompletedProcess: 1739 | def __init__(self): 1740 | self.returncode = 0 1741 | self.stdout = "===== 3 passed in 0.05s =====" 1742 | self.stderr = "" 1743 | 1744 | return MockCompletedProcess() 1745 | 1746 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 1747 | monkeypatch.setattr("datetime.datetime", MockDateTime) 1748 | 1749 | test_path = "tests/test_module.py" 1750 | run_tests_fn = self.get_tool_fn(server, "run_tests") 1751 | result = await run_tests_fn(test_path=test_path) 1752 | 1753 | assert result["status"] == "success" 1754 | assert result["returncode"] == 0 1755 | assert test_path in expected_cmd 1756 | assert "3 passed" in result["stdout"] 1757 | 1758 | @pytest.mark.asyncio 1759 | async def test_run_tests_with_test_name(self, server, monkeypatch): 1760 | """Test run_tests with a specific test name.""" 1761 | expected_cmd = "" 1762 | 1763 | def mock_subprocess_run(*args, **kwargs): 1764 | nonlocal expected_cmd 1765 | expected_cmd = " ".join(args[0]) 1766 | 1767 | class MockCompletedProcess: 1768 | def __init__(self): 1769 | self.returncode = 0 1770 | self.stdout = "===== 1 passed in 0.01s =====" 1771 | self.stderr = "" 1772 | 1773 | return MockCompletedProcess() 1774 | 1775 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 1776 | monkeypatch.setattr("datetime.datetime", MockDateTime) 1777 | 1778 | test_name = "test_specific_function" 1779 | run_tests_fn = self.get_tool_fn(server, "run_tests") 1780 | result = await run_tests_fn(test_name=test_name) 1781 | 1782 | assert result["status"] == "success" 1783 | assert result["returncode"] == 0 1784 | assert f"-k {test_name}" in expected_cmd 1785 | assert "1 passed" in result["stdout"] 1786 | 1787 | @pytest.mark.asyncio 1788 | async def test_run_tests_verbose(self, server, monkeypatch): 1789 | """Test run_tests with verbose flag.""" 1790 | expected_cmd = "" 1791 | 1792 | def mock_subprocess_run(*args, **kwargs): 1793 | nonlocal expected_cmd 1794 | expected_cmd = " ".join(args[0]) 1795 | 1796 | class MockCompletedProcess: 1797 | def __init__(self): 1798 | self.returncode = 0 1799 | self.stdout = "===== verbose output =====" 1800 | self.stderr = "" 1801 | 1802 | return MockCompletedProcess() 1803 | 1804 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 1805 | monkeypatch.setattr("datetime.datetime", MockDateTime) 1806 | 1807 | run_tests_fn = self.get_tool_fn(server, "run_tests") 1808 | result = await run_tests_fn(verbose=True) 1809 | 1810 | assert result["status"] == "success" 1811 | assert result["returncode"] == 0 1812 | assert "-v" in expected_cmd 1813 | 1814 | @pytest.mark.asyncio 1815 | async def test_run_tests_collect_only(self, server, monkeypatch): 1816 | """Test run_tests with collect_only flag.""" 1817 | expected_cmd = "" 1818 | 1819 | def mock_subprocess_run(*args, **kwargs): 1820 | nonlocal expected_cmd 1821 | expected_cmd = " ".join(args[0]) 1822 | 1823 | class MockCompletedProcess: 1824 | def __init__(self): 1825 | self.returncode = 0 1826 | self.stdout = "collected 10 items" 1827 | self.stderr = "" 1828 | 1829 | return MockCompletedProcess() 1830 | 1831 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 1832 | monkeypatch.setattr("datetime.datetime", MockDateTime) 1833 | 1834 | run_tests_fn = self.get_tool_fn(server, "run_tests") 1835 | result = await run_tests_fn(collect_only=True) 1836 | 1837 | assert result["status"] == "success" 1838 | assert result["returncode"] == 0 1839 | assert "--collect-only" in expected_cmd 1840 | assert "collected 10 items" in result["stdout"] 1841 | 1842 | @pytest.mark.asyncio 1843 | async def test_run_tests_failure(self, server, monkeypatch): 1844 | """Test run_tests when tests fail.""" 1845 | 1846 | def mock_subprocess_run(*args, **kwargs): 1847 | class MockCompletedProcess: 1848 | def __init__(self): 1849 | self.returncode = 1 1850 | self.stdout = "===== 2 failed, 3 passed in 0.05s =====" 1851 | self.stderr = "E AssertionError: expected 5 but got 6" 1852 | 1853 | return MockCompletedProcess() 1854 | 1855 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 1856 | monkeypatch.setattr("datetime.datetime", MockDateTime) 1857 | 1858 | run_tests_fn = self.get_tool_fn(server, "run_tests") 1859 | result = await run_tests_fn() 1860 | 1861 | assert result["status"] == "failure" 1862 | assert result["returncode"] == 1 1863 | assert "2 failed" in result["stdout"] 1864 | assert "AssertionError" in result["stderr"] 1865 | 1866 | @pytest.mark.asyncio 1867 | async def test_run_tests_error(self, server, monkeypatch): 1868 | """Test run_tests when an exception occurs.""" 1869 | 1870 | def mock_subprocess_run(*args, **kwargs): 1871 | raise Exception("Command execution failed") 1872 | 1873 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 1874 | run_tests_fn = self.get_tool_fn(server, "run_tests") 1875 | result = await run_tests_fn() 1876 | 1877 | assert result["status"] == "error" 1878 | assert "error" in result 1879 | assert "Command execution failed" in result["error"] 1880 | 1881 | @pytest.mark.asyncio 1882 | async def test_run_tests_with_env_python_venv(self, server, monkeypatch): 1883 | """Test run_tests using Python virtual environment from environment variable.""" 1884 | expected_cmd = "" 1885 | 1886 | def mock_subprocess_run(*args, **kwargs): 1887 | nonlocal expected_cmd 1888 | expected_cmd = " ".join(args[0]) 1889 | 1890 | class MockCompletedProcess: 1891 | def __init__(self): 1892 | self.returncode = 0 1893 | self.stdout = "===== All tests passed =====" 1894 | self.stderr = "" 1895 | 1896 | return MockCompletedProcess() 1897 | 1898 | monkeypatch.setattr("subprocess.run", mock_subprocess_run) 1899 | monkeypatch.setattr("datetime.datetime", MockDateTime) 1900 | 1901 | # Set the python_venv at the server level (environment variable simulation) 1902 | env_venv_path = "/env/path/to/python" 1903 | server.python_venv = env_venv_path 1904 | 1905 | run_tests_fn = self.get_tool_fn(server, "run_tests") 1906 | result = await run_tests_fn() 1907 | 1908 | assert result["status"] == "success" 1909 | assert result["returncode"] == 0 1910 | assert env_venv_path in expected_cmd 1911 | assert env_venv_path == expected_cmd.split()[0] # Should be first in command 1912 | 1913 | 1914 | # Helper class to mock datetime for consistent duration in tests 1915 | class MockDateTime: 1916 | _now_counter = 0 1917 | 1918 | @classmethod 1919 | def now(cls): 1920 | cls._now_counter += 1 1921 | return cls.MockDatetimeValue(0 if cls._now_counter % 2 == 1 else 0.5) 1922 | 1923 | class MockDatetimeValue: 1924 | def __init__(self, value): 1925 | self.value = value 1926 | 1927 | def __sub__(self, other): 1928 | return MockDateTime.MockTimeDelta(0.5) # Always return 0.5 seconds 1929 | 1930 | class MockTimeDelta: 1931 | def __init__(self, seconds): 1932 | self.seconds = seconds 1933 | 1934 | def total_seconds(self): 1935 | return self.seconds 1936 | --------------------------------------------------------------------------------