Skip to content

Commit eb77392

Browse files
committed
Merge branch 'feature/add-hints' into develop
2 parents aa1f1b3 + 041f52c commit eb77392

File tree

7 files changed

+442
-78
lines changed

7 files changed

+442
-78
lines changed

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 tumf
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
A Model Context Protocol (MCP) server that provides line-oriented text file editing capabilities through a standardized API. Optimized for LLM tools with efficient partial file access to minimize token usage.
66

7+
<a href="https://glama.ai/mcp/servers/k44dnvso10"><img width="380" height="200" src="https://glama.ai/mcp/servers/k44dnvso10/badge" alt="mcp-text-editor MCP server" /></a>
8+
79
## Quick Start for Claude.app Users
810

911
To use this editor with Claude.app, add the following configuration to your prompt:

src/mcp_text_editor/text_editor.py

Lines changed: 92 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -11,32 +11,6 @@
1111
logger = logging.getLogger(__name__)
1212

1313

14-
def _create_error_response(
15-
error_message: str,
16-
content_hash: Optional[str] = None,
17-
file_path: Optional[str] = None,
18-
) -> Dict[str, Any]:
19-
"""Create a standardized error response.
20-
21-
Args:
22-
error_message (str): The error message to include
23-
content_hash (Optional[str], optional): Hash of the current content if available
24-
file_path (Optional[str], optional): File path to use as dictionary key
25-
26-
Returns:
27-
Dict[str, Any]: Standardized error response structure
28-
"""
29-
error_response = {
30-
"result": "error",
31-
"reason": error_message,
32-
"hash": content_hash,
33-
}
34-
35-
if file_path:
36-
return {file_path: error_response}
37-
return error_response
38-
39-
4014
class TextEditor:
4115
"""Handles text file operations with security checks and conflict detection."""
4216

@@ -45,6 +19,44 @@ def __init__(self):
4519
self._validate_environment()
4620
self.service = TextEditorService()
4721

22+
def create_error_response(
23+
self,
24+
error_message: str,
25+
content_hash: Optional[str] = None,
26+
file_path: Optional[str] = None,
27+
suggestion: Optional[str] = None,
28+
hint: Optional[str] = None,
29+
) -> Dict[str, Any]:
30+
"""Create a standardized error response.
31+
32+
Args:
33+
error_message (str): The error message to include
34+
content_hash (Optional[str], optional): Hash of the current content if available
35+
file_path (Optional[str], optional): File path to use as dictionary key
36+
suggestion (Optional[str], optional): Suggested operation type
37+
hint (Optional[str], optional): Hint message for users
38+
39+
Returns:
40+
Dict[str, Any]: Standardized error response structure
41+
"""
42+
error_response = {
43+
"result": "error",
44+
"reason": error_message,
45+
"file_hash": content_hash,
46+
}
47+
48+
# Add fields if provided
49+
if content_hash is not None:
50+
error_response["file_hash"] = content_hash
51+
if suggestion:
52+
error_response["suggestion"] = suggestion
53+
if hint:
54+
error_response["hint"] = hint
55+
56+
if file_path:
57+
return {file_path: error_response}
58+
return error_response
59+
4860
def _validate_environment(self) -> None:
4961
"""
5062
Validate environment variables and setup.
@@ -241,20 +253,22 @@ async def edit_file_contents(
241253
try:
242254
if not os.path.exists(file_path):
243255
if expected_hash not in ["", None]: # Allow null hash
244-
return {
245-
"result": "error",
246-
"reason": "File not found and non-empty hash provided",
247-
}
256+
return self.create_error_response(
257+
"File not found and non-empty hash provided",
258+
suggestion="append",
259+
hint="For new files, please consider using append_text_file_contents",
260+
)
248261
# Create parent directories if they don't exist
249262
parent_dir = os.path.dirname(file_path)
250263
if parent_dir:
251264
try:
252265
os.makedirs(parent_dir, exist_ok=True)
253266
except OSError as e:
254-
return {
255-
"result": "error",
256-
"reason": f"Failed to create directory: {str(e)}",
257-
}
267+
return self.create_error_response(
268+
f"Failed to create directory: {str(e)}",
269+
suggestion="patch",
270+
hint="Please check file permissions and try again",
271+
)
258272
# Initialize empty state for new file
259273
current_content = ""
260274
current_hash = ""
@@ -277,17 +291,21 @@ async def edit_file_contents(
277291
current_hash = ""
278292
lines = []
279293
elif current_content and expected_hash == "":
280-
return {
281-
"result": "error",
282-
"reason": "Unexpected error - Cannot treat existing file as new",
283-
}
294+
return self.create_error_response(
295+
"Unexpected error - Cannot treat existing file as new",
296+
)
284297
elif current_hash != expected_hash:
285-
return {
286-
"result": "error",
287-
"reason": "FileHash mismatch - Please use get_text_file_contents tool to get current content and hashes, then retry with the updated hashes.",
288-
}
298+
suggestion = "patch"
299+
hint = "Please use get_text_file_contents tool to get the current content and hash"
300+
301+
return self.create_error_response(
302+
"FileHash mismatch - Please use get_text_file_contents tool to get current content and hashes, then retry with the updated hashes.",
303+
suggestion=suggestion,
304+
hint=hint,
305+
)
289306
else:
290307
lines = current_content.splitlines(keepends=True)
308+
lines = current_content.splitlines(keepends=True)
291309

292310
# Convert patches to EditPatch objects
293311
patch_objects = [EditPatch.model_validate(p) for p in patches]
@@ -314,10 +332,11 @@ async def edit_file_contents(
314332
if (start1 <= end2 and end1 >= start2) or (
315333
start2 <= end1 and end2 >= start1
316334
):
317-
return {
318-
"result": "error",
319-
"reason": "Overlapping patches detected",
320-
}
335+
return self.create_error_response(
336+
"Overlapping patches detected",
337+
suggestion="patch",
338+
hint="Please ensure your patches do not overlap",
339+
)
321340

322341
# Apply patches
323342
for patch in sorted_patches:
@@ -364,7 +383,6 @@ async def edit_file_contents(
364383
# New file or empty file - treat as insertion
365384
is_insertion = True
366385
elif start_zero >= len(lines):
367-
# Append mode - start exceeds total lines
368386
is_insertion = True
369387
else:
370388
# For modification mode, check the range_hash
@@ -386,6 +404,8 @@ async def edit_file_contents(
386404
return {
387405
"result": "error",
388406
"reason": "Content range hash mismatch - Please use get_text_file_contents tool with the same start and end to get current content and hashes, then retry with the updated hashes.",
407+
"suggestion": "get",
408+
"hint": "Please run get_text_file_contents first to get current content and hashes",
389409
}
390410

391411
# Prepare new content
@@ -404,18 +424,18 @@ async def edit_file_contents(
404424
}
405425

406426
# Set suggestions for alternative tools
407-
suggestion = None
408-
hint = None
427+
suggestion_text: Optional[str] = None
428+
hint_text: Optional[str] = None
409429
if not os.path.exists(file_path) or not current_content:
410-
suggestion = "append"
411-
hint = "For new or empty files, please consider using append_text_file_contents instead"
430+
suggestion_text = "append"
431+
hint_text = "For new or empty files, please consider using append_text_file_contents instead"
412432
elif is_insertion:
413433
if start_zero >= len(lines):
414-
suggestion = "append"
415-
hint = "For adding content at the end of file, please consider using append_text_file_contents instead"
434+
suggestion_text = "append"
435+
hint_text = "For adding content at the end of file, please consider using append_text_file_contents instead"
416436
else:
417-
suggestion = "insert"
418-
hint = "For inserting content within file, please consider using insert_text_file_contents instead"
437+
suggestion_text = "insert"
438+
hint_text = "For inserting content within file, please consider using insert_text_file_contents instead"
419439

420440
# Prepare the content
421441
new_content = contents if contents.endswith("\n") else contents + "\n"
@@ -441,24 +461,32 @@ async def edit_file_contents(
441461
"result": "ok",
442462
"file_hash": new_hash,
443463
"reason": None,
444-
"suggestion": suggestion,
445-
"hint": hint,
464+
"suggestion": suggestion_text,
465+
"hint": hint_text,
446466
}
447467

448468
except FileNotFoundError:
449-
return {"result": "error", "reason": f"File not found: {file_path}"}
469+
return self.create_error_response(
470+
f"File not found: {file_path}",
471+
suggestion="append",
472+
hint="For new files, please use append_text_file_contents",
473+
)
450474
except (IOError, UnicodeError, PermissionError) as e:
451-
return {"result": "error", "reason": f"Error editing file: {str(e)}"}
475+
return self.create_error_response(
476+
f"Error editing file: {str(e)}",
477+
suggestion="patch",
478+
hint="Please check file permissions and try again",
479+
)
452480
except Exception as e:
453481
import traceback
454482

455483
logger.error(f"Error: {str(e)}")
456484
logger.error(f"Traceback:\n{traceback.format_exc()}")
457-
return {
458-
"result": "error",
459-
"reason": "Unexpected error occurred",
460-
"file_hash": None,
461-
}
485+
return self.create_error_response(
486+
"Unexpected error occurred",
487+
suggestion="patch",
488+
hint="Please try again or report the issue if it persists",
489+
)
462490

463491
async def insert_text_file_contents(
464492
self,

tests/test_create_error_response.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Tests for error response creation and hint/suggestion functionality."""
2+
3+
import pytest
4+
5+
from mcp_text_editor.text_editor import TextEditor
6+
7+
8+
@pytest.fixture
9+
def editor():
10+
"""Create TextEditor instance."""
11+
return TextEditor()
12+
13+
14+
def test_create_error_response_basic(editor):
15+
"""Test basic error response without hint/suggestion."""
16+
response = editor.create_error_response("Test error")
17+
assert response["result"] == "error"
18+
assert response["reason"] == "Test error"
19+
assert response["file_hash"] is None
20+
assert "hint" not in response
21+
assert "suggestion" not in response
22+
23+
24+
def test_create_error_response_with_hint_suggestion(editor):
25+
"""Test error response with hint and suggestion."""
26+
response = editor.create_error_response(
27+
"Test error", suggestion="append", hint="Please use append_text_file_contents"
28+
)
29+
assert response["result"] == "error"
30+
assert response["reason"] == "Test error"
31+
assert response["suggestion"] == "append"
32+
assert response["hint"] == "Please use append_text_file_contents"
33+
34+
35+
def test_create_error_response_with_file_path(editor):
36+
"""Test error response with file path."""
37+
response = editor.create_error_response(
38+
"Test error",
39+
file_path="/test/file.txt",
40+
suggestion="patch",
41+
hint="Please try again",
42+
)
43+
assert "/test/file.txt" in response
44+
assert response["/test/file.txt"]["result"] == "error"
45+
assert response["/test/file.txt"]["reason"] == "Test error"
46+
assert response["/test/file.txt"]["suggestion"] == "patch"
47+
assert response["/test/file.txt"]["hint"] == "Please try again"
48+
49+
50+
def test_create_error_response_with_hash(editor):
51+
"""Test error response with content hash."""
52+
test_hash = "test_hash_value"
53+
response = editor.create_error_response("Test error", content_hash=test_hash)
54+
assert response["result"] == "error"
55+
assert response["reason"] == "Test error"
56+
assert response["file_hash"] == test_hash

0 commit comments

Comments
 (0)