Skip to content

Commit ce8774d

Browse files
author
Yoshihiro Takahara
committed
feat: Add delete text file operation
- Add DeleteTextFileContentsRequest model - Implement delete_text_file_contents functionality - Add comprehensive test suite - Add server handler for delete operation
1 parent 584c87c commit ce8774d

File tree

4 files changed

+398
-0
lines changed

4 files changed

+398
-0
lines changed

src/mcp_text_editor/models.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,34 @@ def validate_line_number(cls, v) -> Optional[int]:
166166
if v is not None and v < 1:
167167
raise ValueError("Line numbers must be positive")
168168
return v
169+
170+
171+
class DeleteTextFileContentsRequest(BaseModel):
172+
"""Request model for deleting text from a file.
173+
174+
Example:
175+
{
176+
"path": "/path/to/file",
177+
"file_hash": "abc123...",
178+
"ranges": [
179+
{
180+
"start": 5,
181+
"end": 10,
182+
"range_hash": "def456..."
183+
}
184+
]
185+
}
186+
"""
187+
188+
path: str = Field(..., description="Path to the text file")
189+
file_hash: str = Field(..., description="Hash of original contents")
190+
ranges: List[FileRange] = Field(..., description="List of ranges to delete")
191+
192+
@field_validator("range_hash")
193+
def validate_range_hash(cls, v: str) -> str:
194+
"""Validate that range_hash is not empty."""
195+
if not v:
196+
raise ValueError("range_hash cannot be empty")
197+
return v
198+
199+
range_hash: str = Field(..., description="Hash of the content to be deleted")

src/mcp_text_editor/server.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,11 +416,117 @@ async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
416416
raise RuntimeError(f"Error processing request: {str(e)}") from e
417417

418418

419+
class DeleteTextFileContentsHandler:
420+
"""Handler for deleting content from a text file."""
421+
422+
name = "delete_text_file_contents"
423+
description = "Delete specified content ranges from a text file. The file must exist. File paths must be absolute."
424+
425+
def __init__(self):
426+
self.editor = TextEditor()
427+
428+
def get_tool_description(self) -> Tool:
429+
"""Get the tool description."""
430+
return Tool(
431+
name=self.name,
432+
description=self.description,
433+
inputSchema={
434+
"type": "object",
435+
"properties": {
436+
"file_path": {
437+
"type": "string",
438+
"description": "Path to the text file. File path must be absolute.",
439+
},
440+
"file_hash": {
441+
"type": "string",
442+
"description": "Hash of the file contents for concurrency control",
443+
},
444+
"ranges": {
445+
"type": "array",
446+
"description": "List of line ranges to delete",
447+
"items": {
448+
"type": "object",
449+
"properties": {
450+
"start": {
451+
"type": "integer",
452+
"description": "Starting line number (1-based)",
453+
},
454+
"end": {
455+
"type": ["integer", "null"],
456+
"description": "Ending line number (null for end of file)",
457+
},
458+
"range_hash": {
459+
"type": "string",
460+
"description": "Hash of the content being deleted",
461+
},
462+
},
463+
"required": ["start", "range_hash"],
464+
},
465+
},
466+
"encoding": {
467+
"type": "string",
468+
"description": "Text encoding (default: 'utf-8')",
469+
"default": "utf-8",
470+
},
471+
},
472+
"required": ["file_path", "file_hash", "ranges"],
473+
},
474+
)
475+
476+
async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
477+
"""Execute the tool with given arguments."""
478+
try:
479+
# Input validation
480+
if "file_path" not in arguments:
481+
raise RuntimeError("Missing required argument: file_path")
482+
if "file_hash" not in arguments:
483+
raise RuntimeError("Missing required argument: file_hash")
484+
if "ranges" not in arguments:
485+
raise RuntimeError("Missing required argument: ranges")
486+
487+
file_path = arguments["file_path"]
488+
if not os.path.isabs(file_path):
489+
raise RuntimeError(f"File path must be absolute: {file_path}")
490+
491+
# Check if file exists
492+
if not os.path.exists(file_path):
493+
raise RuntimeError(f"File does not exist: {file_path}")
494+
495+
encoding = arguments.get("encoding", "utf-8")
496+
497+
# Create patches for deletion (replacing content with empty string)
498+
patches = [
499+
{
500+
"start": r["start"],
501+
"end": r["end"],
502+
"contents": "",
503+
"range_hash": r["range_hash"],
504+
}
505+
for r in arguments["ranges"]
506+
]
507+
508+
# Use the existing edit_file_contents method
509+
result = await self.editor.edit_file_contents(
510+
file_path,
511+
expected_hash=arguments["file_hash"],
512+
patches=patches,
513+
encoding=encoding,
514+
)
515+
516+
return [TextContent(type="text", text=json.dumps(result, indent=2))]
517+
518+
except Exception as e:
519+
logger.error(f"Error processing request: {str(e)}")
520+
logger.error(traceback.format_exc())
521+
raise RuntimeError(f"Error processing request: {str(e)}") from e
522+
523+
419524
# Initialize tool handlers
420525
get_contents_handler = GetTextFileContentsHandler()
421526
edit_contents_handler = EditTextFileContentsHandler()
422527
create_file_handler = CreateTextFileHandler()
423528
append_file_handler = AppendTextFileContentsHandler()
529+
delete_contents_handler = DeleteTextFileContentsHandler()
424530

425531

426532
@app.list_tools()
@@ -431,6 +537,7 @@ async def list_tools() -> List[Tool]:
431537
edit_contents_handler.get_tool_description(),
432538
create_file_handler.get_tool_description(),
433539
append_file_handler.get_tool_description(),
540+
delete_contents_handler.get_tool_description(),
434541
]
435542

436543

@@ -447,6 +554,8 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
447554
return await create_file_handler.run_tool(arguments)
448555
elif name == append_file_handler.name:
449556
return await append_file_handler.run_tool(arguments)
557+
elif name == delete_contents_handler.name:
558+
return await delete_contents_handler.run_tool(arguments)
450559
else:
451560
raise ValueError(f"Unknown tool: {name}")
452561
except ValueError:

src/mcp_text_editor/text_editor.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,3 +536,104 @@ async def insert_text_file_contents(
536536
"reason": str(e),
537537
"hash": None,
538538
}
539+
540+
async def delete_text_file_contents(
541+
self,
542+
file_path: str,
543+
file_hash: str,
544+
range_hash: str,
545+
start_line: int,
546+
end_line: int,
547+
encoding: str = "utf-8",
548+
) -> Dict[str, Any]:
549+
"""Delete lines from a text file.
550+
551+
Args:
552+
file_path (str): Path to the text file
553+
file_hash (str): Expected hash of the file before editing
554+
range_hash (str): Expected hash of the range to be deleted
555+
start_line (int): Starting line number (1-based)
556+
end_line (int): Ending line number (inclusive)
557+
encoding (str, optional): File encoding. Defaults to "utf-8".
558+
559+
Returns:
560+
Dict[str, Any]: Results containing:
561+
- result: "ok" or "error"
562+
- hash: New file hash if successful
563+
- reason: Error message if result is "error"
564+
"""
565+
try:
566+
# Read current content and verify hash
567+
content, _, _, current_hash, total_lines, _ = await self.read_file_contents(
568+
file_path=file_path,
569+
encoding=encoding,
570+
)
571+
572+
if current_hash != file_hash:
573+
return {
574+
"result": "error",
575+
"reason": "File hash mismatch - Please use get_text_file_contents tool to get current content and hash",
576+
"file_hash": None,
577+
}
578+
579+
# Validate line range
580+
if end_line < start_line:
581+
return {
582+
"result": "error",
583+
"reason": "End line must be greater than or equal to start line",
584+
"file_hash": None,
585+
}
586+
587+
if start_line > total_lines or end_line > total_lines:
588+
return {
589+
"result": "error",
590+
"reason": "Line number out of range",
591+
"file_hash": None,
592+
}
593+
594+
# Verify range hash
595+
range_content = await self.read_file_contents(
596+
file_path=file_path,
597+
start=start_line,
598+
end=end_line,
599+
encoding=encoding,
600+
)
601+
if self.calculate_hash(range_content[0]) != range_hash:
602+
return {
603+
"result": "error",
604+
"reason": "Range hash mismatch - Please use get_text_file_contents tool to get current content and hashes",
605+
"file_hash": None,
606+
}
607+
608+
# Split into lines, preserving line endings
609+
lines = content.splitlines(keepends=True)
610+
611+
# Delete lines (convert to zero-based index)
612+
del lines[start_line - 1 : end_line]
613+
614+
# Write the updated content back to file
615+
final_content = "".join(lines)
616+
with open(file_path, "w", encoding=encoding) as f:
617+
f.write(final_content)
618+
619+
# Calculate new hash
620+
new_hash = self.calculate_hash(final_content)
621+
622+
return {
623+
"result": "ok",
624+
"file_hash": new_hash,
625+
"reason": None,
626+
}
627+
628+
except FileNotFoundError:
629+
return {
630+
"result": "error",
631+
"reason": f"File not found: {file_path}",
632+
"file_hash": None,
633+
}
634+
except Exception as e:
635+
return {
636+
"result": "error",
637+
"reason": str(e),
638+
"file_hash": None,
639+
}

0 commit comments

Comments
 (0)