From 59cac7964c312c233183db39fabf1769ab4f9f36 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Apr 2025 07:48:11 +0000 Subject: [PATCH 1/4] Add validator command option after updating files (#4) Co-Authored-By: tumf --- pyproject.toml | 2 +- src/mcp_text_editor/__init__.py | 2 +- src/mcp_text_editor/server.py | 73 +++++++++++++++++++++--------- src/mcp_text_editor/text_editor.py | 51 +++++++++++++++++++-- tests/test_validator.py | 47 +++++++++++++++++++ 5 files changed, 147 insertions(+), 28 deletions(-) create mode 100644 tests/test_validator.py diff --git a/pyproject.toml b/pyproject.toml index 3a7586f..c258903 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "mcp>=1.1.2", "chardet>=5.2.0", ] -requires-python = ">=3.13" +requires-python = ">=3.12" readme = "README.md" license = { text = "MIT" } diff --git a/src/mcp_text_editor/__init__.py b/src/mcp_text_editor/__init__.py index de99306..d21e788 100644 --- a/src/mcp_text_editor/__init__.py +++ b/src/mcp_text_editor/__init__.py @@ -6,7 +6,7 @@ from .text_editor import TextEditor # Create a global text editor instance -_text_editor = TextEditor() +_text_editor = None def run() -> None: diff --git a/src/mcp_text_editor/server.py b/src/mcp_text_editor/server.py index ae49e25..3708ec5 100644 --- a/src/mcp_text_editor/server.py +++ b/src/mcp_text_editor/server.py @@ -4,7 +4,7 @@ import logging import traceback from collections.abc import Sequence -from typing import Any, List +from typing import Any, List, Optional from mcp.server import Server from mcp.types import TextContent, Tool @@ -17,6 +17,7 @@ InsertTextFileContentsHandler, PatchTextFileContentsHandler, ) +from mcp_text_editor.text_editor import TextEditor from mcp_text_editor.version import __version__ # Configure logging @@ -25,26 +26,32 @@ app: Server = Server("mcp-text-editor") -# Initialize tool handlers -get_contents_handler = GetTextFileContentsHandler() -patch_file_handler = PatchTextFileContentsHandler() -create_file_handler = CreateTextFileHandler() -append_file_handler = AppendTextFileContentsHandler() -delete_contents_handler = DeleteTextFileContentsHandler() -insert_file_handler = InsertTextFileContentsHandler() +# Initialize tool handlers as global variables +get_contents_handler: Optional[GetTextFileContentsHandler] = None +patch_file_handler: Optional[PatchTextFileContentsHandler] = None +create_file_handler: Optional[CreateTextFileHandler] = None +append_file_handler: Optional[AppendTextFileContentsHandler] = None +delete_contents_handler: Optional[DeleteTextFileContentsHandler] = None +insert_file_handler: Optional[InsertTextFileContentsHandler] = None @app.list_tools() async def list_tools() -> List[Tool]: """List available tools.""" - return [ - get_contents_handler.get_tool_description(), - create_file_handler.get_tool_description(), - append_file_handler.get_tool_description(), - delete_contents_handler.get_tool_description(), - insert_file_handler.get_tool_description(), - patch_file_handler.get_tool_description(), - ] + tools = [] + if get_contents_handler: + tools.append(get_contents_handler.get_tool_description()) + if create_file_handler: + tools.append(create_file_handler.get_tool_description()) + if append_file_handler: + tools.append(append_file_handler.get_tool_description()) + if delete_contents_handler: + tools.append(delete_contents_handler.get_tool_description()) + if insert_file_handler: + tools.append(insert_file_handler.get_tool_description()) + if patch_file_handler: + tools.append(patch_file_handler.get_tool_description()) + return tools @app.call_tool() @@ -52,17 +59,17 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]: """Handle tool calls.""" logger.info(f"Calling tool: {name}") try: - if name == get_contents_handler.name: + if get_contents_handler and name == get_contents_handler.name: return await get_contents_handler.run_tool(arguments) - elif name == create_file_handler.name: + elif create_file_handler and name == create_file_handler.name: return await create_file_handler.run_tool(arguments) - elif name == append_file_handler.name: + elif append_file_handler and name == append_file_handler.name: return await append_file_handler.run_tool(arguments) - elif name == delete_contents_handler.name: + elif delete_contents_handler and name == delete_contents_handler.name: return await delete_contents_handler.run_tool(arguments) - elif name == insert_file_handler.name: + elif insert_file_handler and name == insert_file_handler.name: return await insert_file_handler.run_tool(arguments) - elif name == patch_file_handler.name: + elif patch_file_handler and name == patch_file_handler.name: return await patch_file_handler.run_tool(arguments) else: raise ValueError(f"Unknown tool: {name}") @@ -76,7 +83,29 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]: async def main() -> None: """Main entry point for the MCP text editor server.""" + import argparse + import sys + + parser = argparse.ArgumentParser(description="MCP Text Editor Server") + parser.add_argument("--validator", help="Validator command to run after file updates") + args = parser.parse_args() + logger.info(f"Starting MCP text editor server v{__version__}") + + # Initialize the global text editor instance with validator command + text_editor_instance = TextEditor(validator_command=args.validator) + + # Initialize the global handlers with the text editor instance + global get_contents_handler, patch_file_handler, create_file_handler + global append_file_handler, delete_contents_handler, insert_file_handler + + get_contents_handler = GetTextFileContentsHandler(editor=text_editor_instance) + patch_file_handler = PatchTextFileContentsHandler(editor=text_editor_instance) + create_file_handler = CreateTextFileHandler(editor=text_editor_instance) + append_file_handler = AppendTextFileContentsHandler(editor=text_editor_instance) + delete_contents_handler = DeleteTextFileContentsHandler(editor=text_editor_instance) + insert_file_handler = InsertTextFileContentsHandler(editor=text_editor_instance) + try: from mcp.server.stdio import stdio_server diff --git a/src/mcp_text_editor/text_editor.py b/src/mcp_text_editor/text_editor.py index 5789e55..a7807f0 100644 --- a/src/mcp_text_editor/text_editor.py +++ b/src/mcp_text_editor/text_editor.py @@ -14,10 +14,11 @@ class TextEditor: """Handles text file operations with security checks and conflict detection.""" - def __init__(self): + def __init__(self, validator_command: Optional[str] = None): """Initialize TextEditor.""" self._validate_environment() self.service = TextEditorService() + self.validator_command = validator_command def create_error_response( self, @@ -466,13 +467,16 @@ async def edit_file_contents( # Calculate new hash new_hash = self.calculate_hash(final_content) - + + validator_result = self._run_validator(file_path) + return { "result": "ok", "file_hash": new_hash, "reason": None, "suggestion": suggestion_text, "hint": hint_text, + "validator_result": validator_result, } except FileNotFoundError: @@ -588,11 +592,14 @@ async def insert_text_file_contents( # Calculate new hash new_hash = self.calculate_hash(final_content) - + + validator_result = self._run_validator(file_path) + return { "result": "ok", "hash": new_hash, "reason": None, + "validator_result": validator_result } except FileNotFoundError: @@ -739,12 +746,15 @@ async def delete_text_file_contents( # Calculate new hash new_hash = self.calculate_hash(final_content) - + + validator_result = self._run_validator(request.file_path) + return { request.file_path: { "result": "ok", "hash": new_hash, "reason": None, + "validator_result": validator_result, } } @@ -764,3 +774,36 @@ async def delete_text_file_contents( "hash": None, } } + def _run_validator(self, file_path: str) -> Optional[Dict[str, Any]]: + """Run validator command on the updated file if configured. + + Args: + file_path (str): Path to the file to validate + + Returns: + Optional[Dict[str, Any]]: Validation result or None if validation was not run + """ + logger.debug(f"Running validator with command: {self.validator_command} on file: {file_path}") + if not self.validator_command: + logger.debug("No validator command configured, skipping validation") + return None + + try: + import subprocess + logger.debug(f"Executing subprocess.run with: {[self.validator_command, file_path]}") + result = subprocess.run( + [self.validator_command, file_path], + capture_output=True, + text=True, + check=False + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr + } + except Exception as e: + logger.error(f"Error running validator command: {str(e)}") + return { + "error": str(e) + } diff --git a/tests/test_validator.py b/tests/test_validator.py new file mode 100644 index 0000000..398bc86 --- /dev/null +++ b/tests/test_validator.py @@ -0,0 +1,47 @@ +import os +import pytest +from unittest.mock import patch, MagicMock + +from mcp_text_editor.text_editor import TextEditor + + +@pytest.fixture +def temp_file(tmp_path): + """Create a temporary file for testing.""" + file_path = tmp_path / "test.txt" + with open(file_path, "w") as f: + f.write("Original content\n") + return str(file_path) + + +@pytest.mark.asyncio +async def test_validator_command_runs_after_edit(temp_file): + """Test that validator command runs after editing a file.""" + with patch("subprocess.run") as mock_run: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Validation passed" + mock_result.stderr = "" + mock_run.return_value = mock_result + + editor = TextEditor(validator_command="test-validator") + + with open(temp_file, "r") as f: + content = f.read() + file_hash = editor.calculate_hash(content) + result = await editor.edit_file_contents( + file_path=temp_file, + expected_file_hash=file_hash, # Use actual file hash + patches=[{"start": 1, "end": 1, "contents": "Updated content\n", "range_hash": editor.calculate_hash("Original content\n")}] + ) + + mock_run.assert_called_once_with( + ["test-validator", temp_file], + capture_output=True, + text=True, + check=False + ) + + assert "validator_result" in result + assert result["validator_result"]["exit_code"] == 0 + assert result["validator_result"]["stdout"] == "Validation passed" From 0a196adee0203a195503cb7ba7ea77fe343e138b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Apr 2025 07:49:59 +0000 Subject: [PATCH 2/4] Fix formatting issues with black Co-Authored-By: tumf --- src/mcp_text_editor/server.py | 16 +++++++------ src/mcp_text_editor/text_editor.py | 38 +++++++++++++++++------------- tests/test_validator.py | 20 +++++++++------- 3 files changed, 42 insertions(+), 32 deletions(-) diff --git a/src/mcp_text_editor/server.py b/src/mcp_text_editor/server.py index 3708ec5..dcb4499 100644 --- a/src/mcp_text_editor/server.py +++ b/src/mcp_text_editor/server.py @@ -85,27 +85,29 @@ async def main() -> None: """Main entry point for the MCP text editor server.""" import argparse import sys - + parser = argparse.ArgumentParser(description="MCP Text Editor Server") - parser.add_argument("--validator", help="Validator command to run after file updates") + parser.add_argument( + "--validator", help="Validator command to run after file updates" + ) args = parser.parse_args() - + logger.info(f"Starting MCP text editor server v{__version__}") - + # Initialize the global text editor instance with validator command text_editor_instance = TextEditor(validator_command=args.validator) - + # Initialize the global handlers with the text editor instance global get_contents_handler, patch_file_handler, create_file_handler global append_file_handler, delete_contents_handler, insert_file_handler - + get_contents_handler = GetTextFileContentsHandler(editor=text_editor_instance) patch_file_handler = PatchTextFileContentsHandler(editor=text_editor_instance) create_file_handler = CreateTextFileHandler(editor=text_editor_instance) append_file_handler = AppendTextFileContentsHandler(editor=text_editor_instance) delete_contents_handler = DeleteTextFileContentsHandler(editor=text_editor_instance) insert_file_handler = InsertTextFileContentsHandler(editor=text_editor_instance) - + try: from mcp.server.stdio import stdio_server diff --git a/src/mcp_text_editor/text_editor.py b/src/mcp_text_editor/text_editor.py index a7807f0..ac594a4 100644 --- a/src/mcp_text_editor/text_editor.py +++ b/src/mcp_text_editor/text_editor.py @@ -467,9 +467,9 @@ async def edit_file_contents( # Calculate new hash new_hash = self.calculate_hash(final_content) - + validator_result = self._run_validator(file_path) - + return { "result": "ok", "file_hash": new_hash, @@ -592,14 +592,14 @@ async def insert_text_file_contents( # Calculate new hash new_hash = self.calculate_hash(final_content) - + validator_result = self._run_validator(file_path) - + return { "result": "ok", "hash": new_hash, "reason": None, - "validator_result": validator_result + "validator_result": validator_result, } except FileNotFoundError: @@ -746,9 +746,9 @@ async def delete_text_file_contents( # Calculate new hash new_hash = self.calculate_hash(final_content) - + validator_result = self._run_validator(request.file_path) - + return { request.file_path: { "result": "ok", @@ -774,36 +774,40 @@ async def delete_text_file_contents( "hash": None, } } + def _run_validator(self, file_path: str) -> Optional[Dict[str, Any]]: """Run validator command on the updated file if configured. - + Args: file_path (str): Path to the file to validate - + Returns: Optional[Dict[str, Any]]: Validation result or None if validation was not run """ - logger.debug(f"Running validator with command: {self.validator_command} on file: {file_path}") + logger.debug( + f"Running validator with command: {self.validator_command} on file: {file_path}" + ) if not self.validator_command: logger.debug("No validator command configured, skipping validation") return None - + try: import subprocess - logger.debug(f"Executing subprocess.run with: {[self.validator_command, file_path]}") + + logger.debug( + f"Executing subprocess.run with: {[self.validator_command, file_path]}" + ) result = subprocess.run( [self.validator_command, file_path], capture_output=True, text=True, - check=False + check=False, ) return { "exit_code": result.returncode, "stdout": result.stdout, - "stderr": result.stderr + "stderr": result.stderr, } except Exception as e: logger.error(f"Error running validator command: {str(e)}") - return { - "error": str(e) - } + return {"error": str(e)} diff --git a/tests/test_validator.py b/tests/test_validator.py index 398bc86..d95f3b8 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -25,23 +25,27 @@ async def test_validator_command_runs_after_edit(temp_file): mock_run.return_value = mock_result editor = TextEditor(validator_command="test-validator") - + with open(temp_file, "r") as f: content = f.read() file_hash = editor.calculate_hash(content) result = await editor.edit_file_contents( file_path=temp_file, expected_file_hash=file_hash, # Use actual file hash - patches=[{"start": 1, "end": 1, "contents": "Updated content\n", "range_hash": editor.calculate_hash("Original content\n")}] + patches=[ + { + "start": 1, + "end": 1, + "contents": "Updated content\n", + "range_hash": editor.calculate_hash("Original content\n"), + } + ], ) - + mock_run.assert_called_once_with( - ["test-validator", temp_file], - capture_output=True, - text=True, - check=False + ["test-validator", temp_file], capture_output=True, text=True, check=False ) - + assert "validator_result" in result assert result["validator_result"]["exit_code"] == 0 assert result["validator_result"]["stdout"] == "Validation passed" From 299550b77e94770286d69b9cd8fcf848ee38ee7b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Apr 2025 07:50:59 +0000 Subject: [PATCH 3/4] Fix import order with isort Co-Authored-By: tumf --- tests/test_validator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_validator.py b/tests/test_validator.py index d95f3b8..2c17184 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -1,6 +1,7 @@ import os +from unittest.mock import MagicMock, patch + import pytest -from unittest.mock import patch, MagicMock from mcp_text_editor.text_editor import TextEditor From ea7c16e1a415be471b672ba4a6dca531b3c1344a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Apr 2025 07:52:38 +0000 Subject: [PATCH 4/4] Fix linting errors: remove unused imports Co-Authored-By: tumf --- src/mcp_text_editor/__init__.py | 2 +- src/mcp_text_editor/server.py | 1 - tests/test_validator.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/mcp_text_editor/__init__.py b/src/mcp_text_editor/__init__.py index d21e788..7c7834e 100644 --- a/src/mcp_text_editor/__init__.py +++ b/src/mcp_text_editor/__init__.py @@ -3,7 +3,7 @@ import asyncio from .server import main -from .text_editor import TextEditor +from .text_editor import TextEditor as TextEditor # Re-export explicitly # Create a global text editor instance _text_editor = None diff --git a/src/mcp_text_editor/server.py b/src/mcp_text_editor/server.py index dcb4499..086ea27 100644 --- a/src/mcp_text_editor/server.py +++ b/src/mcp_text_editor/server.py @@ -84,7 +84,6 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]: async def main() -> None: """Main entry point for the MCP text editor server.""" import argparse - import sys parser = argparse.ArgumentParser(description="MCP Text Editor Server") parser.add_argument( diff --git a/tests/test_validator.py b/tests/test_validator.py index 2c17184..bae63b8 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -1,4 +1,3 @@ -import os from unittest.mock import MagicMock, patch import pytest