Skip to content

Commit 19882be

Browse files
committed
Merge branch 'feature/required-end-parameter' into develop
2 parents 0e16942 + f136326 commit 19882be

File tree

5 files changed

+99
-93
lines changed

5 files changed

+99
-93
lines changed

src/mcp_text_editor/models.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import Dict, List, Optional
44

5-
from pydantic import BaseModel, Field
5+
from pydantic import BaseModel, Field, model_validator
66

77

88
class GetTextFileContentsRequest(BaseModel):
@@ -29,9 +29,22 @@ class EditPatch(BaseModel):
2929
end: Optional[int] = Field(None, description="Ending line for edit")
3030
contents: str = Field(..., description="New content to insert")
3131
range_hash: Optional[str] = Field(
32-
None, description="Hash of content being replaced. None for insertions"
32+
None, # None for new patches, must be explicitly set
33+
description="Hash of content being replaced. Empty string for insertions.",
3334
)
3435

36+
@model_validator(mode="after")
37+
def validate_end_line(self) -> "EditPatch":
38+
"""Validate that end line is present when not in append mode."""
39+
# range_hash must be explicitly set
40+
if self.range_hash is None:
41+
raise ValueError("range_hash is required")
42+
43+
# For modifications (non-empty range_hash), end is required
44+
if self.range_hash != "" and self.end is None:
45+
raise ValueError("end line is required when not in append mode")
46+
return self
47+
3548

3649
class EditFileOperation(BaseModel):
3750
"""Model for individual file edit operation."""

src/mcp_text_editor/text_editor.py

Lines changed: 22 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Core text editor functionality with file operation handling."""
22

33
import hashlib
4+
import logging
45
import os
56
from typing import Any, Dict, List, Optional, Tuple
67

78
from mcp_text_editor.models import EditPatch, FileRanges
89

10+
logger = logging.getLogger(__name__)
11+
912

1013
class TextEditor:
1114
"""Handles text file operations with security checks and conflict detection."""
@@ -338,9 +341,6 @@ async def edit_file_contents(
338341

339342
# Calculate line ranges for zero-based indexing
340343
start_zero = start - 1
341-
end_zero = (
342-
len(lines) - 1 if end is None else min(end - 1, len(lines) - 1)
343-
)
344344

345345
# Get expected hash for validation
346346
expected_range_hash = None
@@ -358,49 +358,30 @@ async def edit_file_contents(
358358
# Append mode - start exceeds total lines
359359
is_insertion = True
360360
else:
361-
# For existing files:
362-
# range_hash is required for modifications
363-
if not expected_range_hash:
364-
return {
365-
"result": "error",
366-
"reason": "range_hash is required for file modifications",
367-
"file_hash": None,
368-
"content": current_content,
369-
}
370-
371-
# Hash provided - verify content
372-
target_lines = lines[start_zero : end_zero + 1]
373-
target_content = "".join(target_lines)
374-
actual_range_hash = self.calculate_hash(target_content)
375-
376-
# Debug output for hash comparison
377-
print(
378-
f"Debug - Range hash comparison:"
379-
f"\nExpected: {expected_range_hash}"
380-
f"\nActual: {actual_range_hash}"
381-
f"\nContent: {target_content!r}"
382-
f"\nRange: {start_zero}:{end_zero + 1}"
383-
)
384-
385-
# Compare hashes
386-
# Empty range_hash means explicit insertion
387-
is_insertion = (
388-
not expected_range_hash
389-
or expected_range_hash == self.calculate_hash("")
390-
)
391-
392-
# For non-insertion operations, verify content hash
361+
# For modification mode, check the range_hash
362+
is_insertion = expected_range_hash == ""
393363
if not is_insertion:
364+
# Calculate end_zero for content validation
365+
end_zero = (
366+
len(lines) - 1
367+
if end is None
368+
else min(end - 1, len(lines) - 1)
369+
)
370+
371+
# Hash provided - verify content
372+
target_lines = lines[start_zero : end_zero + 1]
373+
target_content = "".join(target_lines)
374+
actual_range_hash = self.calculate_hash(target_content)
375+
394376
if actual_range_hash != expected_range_hash:
395377
return {
396378
"result": "error",
397-
"reason": f"Content hash mismatch - Please use get_text_file_contents tool with lines {start}-{end} to get current content and hashes, then retry with the updated hashes. If you want to append content, set start to {len(lines)+1}.",
379+
"reason": "Content range hash mismatch - Please use get_text_file_contents tool to get current content and hashes, then retry with the updated hashes.",
398380
"file_hash": None,
399381
"content": current_content,
400382
}
401383

402384
# Prepare new content
403-
contents: str
404385
if isinstance(patch, EditPatch):
405386
contents = patch.contents
406387
else:
@@ -409,19 +390,14 @@ async def edit_file_contents(
409390
new_content = contents if contents.endswith("\n") else contents + "\n"
410391
new_lines = new_content.splitlines(keepends=True)
411392

412-
# Apply changes - line ranges were calculated earlier
393+
# For insertion mode, we don't need end_zero
413394
if is_insertion:
414395
# Insert at the specified line
415396
lines[start_zero:start_zero] = new_lines
416397
else:
417-
# Replace the specified range
398+
# We already have end_zero for replacements
418399
lines[start_zero : end_zero + 1] = new_lines
419400

420-
# Debug output - shows the operation type
421-
print(
422-
f"Applied patch: start={start} end={end} is_insertion={is_insertion} contents={patch.contents!r}"
423-
)
424-
425401
# Write the final content back to file
426402
final_content = "".join(lines)
427403
with open(file_path, "w", encoding=encoding) as f:
@@ -453,8 +429,8 @@ async def edit_file_contents(
453429
except Exception as e:
454430
import traceback
455431

456-
print(f"Error: {str(e)}")
457-
print(f"Traceback:\n{traceback.format_exc()}")
432+
logger.error(f"Error: {str(e)}")
433+
logger.error(f"Traceback:\n{traceback.format_exc()}")
458434
return {
459435
"result": "error",
460436
"reason": "Unexpected error occurred",

tests/test_models.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,28 +51,41 @@ def test_get_text_file_contents_response():
5151

5252
def test_edit_patch():
5353
"""Test EditPatch model."""
54-
# Test with only required field
55-
patch = EditPatch(contents="new content")
54+
# Test that range_hash is required
55+
with pytest.raises(ValueError, match="range_hash is required"):
56+
EditPatch(contents="new content")
57+
with pytest.raises(ValueError, match="range_hash is required"):
58+
EditPatch(contents="new content", start=1)
59+
60+
# Test append mode with empty range_hash
61+
patch = EditPatch(contents="new content", start=1, range_hash="")
5662
assert patch.contents == "new content"
57-
assert patch.start == 1 # Default value
58-
assert patch.end is None # Default value
63+
assert patch.start == 1
64+
assert patch.end is None
5965

60-
# Test with all fields
61-
patch = EditPatch(start=5, end=10, contents="new content")
66+
# Test modification mode (requires end when range_hash is present)
67+
patch = EditPatch(start=5, end=10, contents="new content", range_hash="somehash")
6268
assert patch.start == 5
6369
assert patch.end == 10
6470
assert patch.contents == "new content"
71+
assert patch.range_hash == "somehash"
6572

6673
# Test validation error - missing required field
6774
with pytest.raises(ValidationError):
6875
EditPatch()
6976

77+
# Test validation error - missing end in modification mode
78+
with pytest.raises(
79+
ValueError, match="end line is required when not in append mode"
80+
):
81+
EditPatch(start=1, contents="content", range_hash="somehash")
82+
7083

7184
def test_edit_file_operation():
7285
"""Test EditFileOperation model."""
7386
patches = [
74-
EditPatch(contents="content1"),
75-
EditPatch(start=2, end=3, contents="content2"),
87+
EditPatch(contents="content1", range_hash=""), # append mode
88+
EditPatch(start=2, end=3, contents="content2", range_hash="somehash"),
7689
]
7790
operation = EditFileOperation(
7891
path="/path/to/file.txt", hash="hash123", patches=patches
@@ -81,7 +94,9 @@ def test_edit_file_operation():
8194
assert operation.hash == "hash123"
8295
assert len(operation.patches) == 2
8396
assert operation.patches[0].contents == "content1"
97+
assert operation.patches[0].range_hash == "" # append mode
8498
assert operation.patches[1].start == 2
99+
assert operation.patches[1].range_hash == "somehash" # modification mode
85100

86101
# Test validation error - missing required fields
87102
with pytest.raises(ValidationError):
@@ -123,7 +138,7 @@ def test_edit_text_file_contents_request():
123138
EditFileOperation(
124139
path="/path/to/file.txt",
125140
hash="hash123",
126-
patches=[EditPatch(contents="new content")],
141+
patches=[EditPatch(contents="new content", range_hash="")],
127142
)
128143
]
129144
)
@@ -139,12 +154,12 @@ def test_edit_text_file_contents_request():
139154
EditFileOperation(
140155
path="/path/to/file1.txt",
141156
hash="hash123",
142-
patches=[EditPatch(contents="content1")],
157+
patches=[EditPatch(contents="content1", range_hash="")],
143158
),
144159
EditFileOperation(
145160
path="/path/to/file2.txt",
146161
hash="hash456",
147-
patches=[EditPatch(start=2, contents="content2")],
162+
patches=[EditPatch(start=2, contents="content2", range_hash="")],
148163
),
149164
]
150165
)

tests/test_service.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,20 @@ def test_validate_patches(service):
4949
"""Test patch validation."""
5050
# Valid patches
5151
patches = [
52-
EditPatch(start=1, end=2, contents="content1"),
53-
EditPatch(start=3, end=4, contents="content2"),
52+
EditPatch(start=1, end=2, contents="content1", range_hash="hash1"),
53+
EditPatch(start=3, end=4, contents="content2", range_hash="hash2"),
5454
]
5555
assert service.validate_patches(patches, 5) is True
5656

5757
# Overlapping patches
5858
patches = [
59-
EditPatch(start=1, end=3, contents="content1"),
60-
EditPatch(start=2, end=4, contents="content2"),
59+
EditPatch(start=1, end=3, contents="content1", range_hash="hash1"),
60+
EditPatch(start=2, end=4, contents="content2", range_hash="hash2"),
6161
]
6262
assert service.validate_patches(patches, 5) is False
6363

6464
# Out of bounds patches
65-
patches = [EditPatch(start=1, end=10, contents="content1")]
65+
patches = [EditPatch(start=1, end=10, contents="content1", range_hash="hash1")]
6666
assert service.validate_patches(patches, 5) is False
6767

6868

@@ -81,7 +81,7 @@ def test_edit_file_contents(service, tmp_path):
8181
operation = EditFileOperation(
8282
path=file_path,
8383
hash=initial_hash,
84-
patches=[EditPatch(start=2, end=2, contents="new line2")],
84+
patches=[EditPatch(start=2, end=2, contents="new line2", range_hash="hash1")],
8585
)
8686

8787
# Apply edit
@@ -108,7 +108,7 @@ def test_edit_file_contents_hash_mismatch(service, tmp_path):
108108
operation = EditFileOperation(
109109
path=file_path,
110110
hash="incorrect_hash",
111-
patches=[EditPatch(start=2, end=2, contents="new line2")],
111+
patches=[EditPatch(start=2, end=2, contents="new line2", range_hash="hash1")],
112112
)
113113

114114
# Attempt edit
@@ -135,7 +135,9 @@ def test_edit_file_contents_invalid_patches(service, tmp_path):
135135
path=file_path,
136136
hash=initial_hash,
137137
patches=[
138-
EditPatch(start=1, end=10, contents="new content") # Beyond file length
138+
EditPatch(
139+
start=1, end=10, contents="new content", range_hash="hash1"
140+
) # Beyond file length
139141
],
140142
)
141143

@@ -152,7 +154,9 @@ def test_edit_file_contents_file_error(service):
152154
file_path = "nonexistent.txt"
153155
# Attempt to edit non-existent file
154156
operation = EditFileOperation(
155-
path=file_path, hash="any_hash", patches=[EditPatch(contents="new content")]
157+
path=file_path,
158+
hash="any_hash",
159+
patches=[EditPatch(contents="new content", range_hash="")],
156160
)
157161

158162
# Attempt edit
@@ -170,7 +174,7 @@ def test_edit_file_unexpected_error(service, tmp_path):
170174
operation = EditFileOperation(
171175
path=test_file,
172176
hash="dummy_hash",
173-
patches=[EditPatch(contents="test content\n", line_start=1)],
177+
patches=[EditPatch(contents="test content\n", start=1, range_hash="")],
174178
)
175179

176180
# Try to edit non-existent file
@@ -198,7 +202,7 @@ def test_edit_file_contents_permission_error(service, tmp_path):
198202
operation = EditFileOperation(
199203
path=file_path,
200204
hash=service.calculate_hash(test_content),
201-
patches=[EditPatch(line_start=2, line_end=2, contents="new line2")],
205+
patches=[EditPatch(start=2, end=2, contents="new line2", range_hash="hash1")],
202206
)
203207

204208
# Attempt edit
@@ -219,7 +223,7 @@ def test_edit_file_contents_general_exception(service, mocker):
219223
operation = EditFileOperation(
220224
path=test_file,
221225
hash="hash123",
222-
patches=[EditPatch(contents="new content", start=1)],
226+
patches=[EditPatch(contents="new content", start=1, range_hash="")],
223227
)
224228

225229
# Mock edit_file to raise an exception

0 commit comments

Comments
 (0)