Skip to content

Commit a87d0b9

Browse files
author
Yoshihiro Takahara
committed
feat: Add insert_text_file_contents function
Add ability to insert text before or after a specific line in a file. - Add InsertTextFileContentsRequest model - Implement insert_text_file_contents in TextEditor - Add comprehensive tests - Export function in __init__.py
1 parent 1f7577e commit a87d0b9

File tree

5 files changed

+363
-1
lines changed

5 files changed

+363
-1
lines changed

src/mcp_text_editor/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,37 @@
11
"""MCP Text Editor Server package."""
22

33
import asyncio
4+
from typing import Any, Dict, List
45

56
from .server import main
7+
from .text_editor import TextEditor
8+
9+
# Create a global text editor instance
10+
_text_editor = TextEditor()
611

712

813
def run() -> None:
914
"""Run the MCP Text Editor Server."""
1015
asyncio.run(main())
16+
17+
18+
# Export functions
19+
async def get_text_file_contents(
20+
request: Dict[str, List[Dict[str, Any]]]
21+
) -> Dict[str, Any]:
22+
"""Get text file contents with line range specification."""
23+
return await _text_editor.read_multiple_ranges(
24+
ranges=request["files"],
25+
encoding="utf-8",
26+
)
27+
28+
29+
async def insert_text_file_contents(request: Dict[str, Any]) -> Dict[str, Any]:
30+
"""Insert text content before or after a specific line in a file."""
31+
return await _text_editor.insert_text_file_contents(
32+
file_path=request["path"],
33+
file_hash=request["file_hash"],
34+
after=request.get("after"),
35+
before=request.get("before"),
36+
contents=request["contents"],
37+
)

src/mcp_text_editor/models.py

Lines changed: 47 additions & 1 deletion
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, model_validator
5+
from pydantic import BaseModel, Field, field_validator, model_validator
66

77

88
class GetTextFileContentsRequest(BaseModel):
@@ -120,3 +120,49 @@ class FileRanges(BaseModel):
120120
ranges: List[FileRange] = Field(
121121
..., description="List of line ranges to read from the file"
122122
)
123+
124+
125+
class InsertTextFileContentsRequest(BaseModel):
126+
"""Request model for inserting text into a file.
127+
128+
Example:
129+
{
130+
"path": "/path/to/file",
131+
"file_hash": "abc123...",
132+
"after": 5, # Insert after line 5
133+
"contents": "new content"
134+
}
135+
or
136+
{
137+
"path": "/path/to/file",
138+
"file_hash": "abc123...",
139+
"before": 5, # Insert before line 5
140+
"contents": "new content"
141+
}
142+
"""
143+
144+
path: str = Field(..., description="Path to the text file")
145+
file_hash: str = Field(..., description="Hash of original contents")
146+
after: Optional[int] = Field(
147+
None, description="Line number after which to insert content"
148+
)
149+
before: Optional[int] = Field(
150+
None, description="Line number before which to insert content"
151+
)
152+
contents: str = Field(..., description="Content to insert")
153+
154+
@model_validator(mode="after")
155+
def validate_position(self) -> "InsertTextFileContentsRequest":
156+
"""Validate that exactly one of after or before is specified."""
157+
if (self.after is None and self.before is None) or (
158+
self.after is not None and self.before is not None
159+
):
160+
raise ValueError("Exactly one of 'after' or 'before' must be specified")
161+
return self
162+
163+
@field_validator("after", "before")
164+
def validate_line_number(cls, v) -> Optional[int]:
165+
"""Validate that line numbers are positive."""
166+
if v is not None and v < 1:
167+
raise ValueError("Line numbers must be positive")
168+
return v

src/mcp_text_editor/text_editor.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,108 @@ async def edit_file_contents(
431431
"reason": "Unexpected error occurred",
432432
"content": None,
433433
}
434+
435+
async def insert_text_file_contents(
436+
self,
437+
file_path: str,
438+
file_hash: str,
439+
contents: str,
440+
after: Optional[int] = None,
441+
before: Optional[int] = None,
442+
encoding: str = "utf-8",
443+
) -> Dict[str, Any]:
444+
"""Insert text content before or after a specific line in a file.
445+
446+
Args:
447+
file_path (str): Path to the file to edit
448+
file_hash (str): Expected hash of the file before editing
449+
contents (str): Content to insert
450+
after (Optional[int]): Line number after which to insert content
451+
before (Optional[int]): Line number before which to insert content
452+
encoding (str, optional): File encoding. Defaults to "utf-8"
453+
454+
Returns:
455+
Dict[str, Any]: Results containing:
456+
- result: "ok" or "error"
457+
- hash: New file hash if successful
458+
- reason: Error message if result is "error"
459+
"""
460+
if (after is None and before is None) or (
461+
after is not None and before is not None
462+
):
463+
return {
464+
"result": "error",
465+
"reason": "Exactly one of 'after' or 'before' must be specified",
466+
"hash": None,
467+
}
468+
469+
try:
470+
current_content, _, _, current_hash, total_lines, _ = (
471+
await self.read_file_contents(
472+
file_path,
473+
encoding=encoding,
474+
)
475+
)
476+
477+
if current_hash != file_hash:
478+
return {
479+
"result": "error",
480+
"reason": "File hash mismatch - Please use get_text_file_contents tool to get current content and hash",
481+
"hash": None,
482+
}
483+
484+
# Split into lines, preserving line endings
485+
lines = current_content.splitlines(keepends=True)
486+
487+
# Determine insertion point
488+
if after is not None:
489+
if after > total_lines:
490+
return {
491+
"result": "error",
492+
"reason": f"Line number {after} is beyond end of file (total lines: {total_lines})",
493+
"hash": None,
494+
}
495+
insert_pos = after
496+
else: # before must be set due to earlier validation
497+
assert before is not None
498+
if before > total_lines + 1:
499+
return {
500+
"result": "error",
501+
"reason": f"Line number {before} is beyond end of file (total lines: {total_lines})",
502+
"hash": None,
503+
}
504+
insert_pos = before - 1
505+
506+
# Ensure content ends with newline
507+
if not contents.endswith("\n"):
508+
contents += "\n"
509+
510+
# Insert the content
511+
lines.insert(insert_pos, contents)
512+
513+
# Join lines and write back to file
514+
final_content = "".join(lines)
515+
with open(file_path, "w", encoding=encoding) as f:
516+
f.write(final_content)
517+
518+
# Calculate new hash
519+
new_hash = self.calculate_hash(final_content)
520+
521+
return {
522+
"result": "ok",
523+
"hash": new_hash,
524+
"reason": None,
525+
}
526+
527+
except FileNotFoundError:
528+
return {
529+
"result": "error",
530+
"reason": f"File not found: {file_path}",
531+
"hash": None,
532+
}
533+
except Exception as e:
534+
return {
535+
"result": "error",
536+
"reason": str(e),
537+
"hash": None,
538+
}

tests/test_insert_text_file.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Test insert_text_file_contents function."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from mcp_text_editor import get_text_file_contents, insert_text_file_contents
8+
9+
10+
@pytest.mark.asyncio
11+
async def test_insert_after_line(tmp_path: Path) -> None:
12+
"""Test inserting text after a specific line."""
13+
# Create a test file
14+
test_file = tmp_path / "test.txt"
15+
test_file.write_text("line1\nline2\nline3\n")
16+
17+
# Get the current hash
18+
result = await get_text_file_contents(
19+
{"files": [{"file_path": str(test_file), "ranges": [{"start": 1}]}]}
20+
)
21+
file_hash = result[str(test_file)]["file_hash"]
22+
23+
# Insert text after line 2
24+
result = await insert_text_file_contents(
25+
{
26+
"path": str(test_file),
27+
"file_hash": file_hash,
28+
"after": 2,
29+
"contents": "new_line\n",
30+
}
31+
)
32+
33+
assert result["result"] == "ok"
34+
assert result["hash"] is not None
35+
36+
# Verify the content
37+
content = test_file.read_text()
38+
assert content == "line1\nline2\nnew_line\nline3\n"
39+
40+
41+
@pytest.mark.asyncio
42+
async def test_insert_before_line(tmp_path: Path) -> None:
43+
"""Test inserting text before a specific line."""
44+
# Create a test file
45+
test_file = tmp_path / "test.txt"
46+
test_file.write_text("line1\nline2\nline3\n")
47+
48+
# Get the current hash
49+
result = await get_text_file_contents(
50+
{"files": [{"file_path": str(test_file), "ranges": [{"start": 1}]}]}
51+
)
52+
file_hash = result[str(test_file)]["file_hash"]
53+
54+
# Insert text before line 2
55+
result = await insert_text_file_contents(
56+
{
57+
"path": str(test_file),
58+
"file_hash": file_hash,
59+
"before": 2,
60+
"contents": "new_line\n",
61+
}
62+
)
63+
64+
assert result["result"] == "ok"
65+
assert result["hash"] is not None
66+
67+
# Verify the content
68+
content = test_file.read_text()
69+
assert content == "line1\nnew_line\nline2\nline3\n"
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_insert_beyond_file_end(tmp_path: Path) -> None:
74+
"""Test inserting text beyond the end of file."""
75+
# Create a test file
76+
test_file = tmp_path / "test.txt"
77+
test_file.write_text("line1\nline2\nline3\n")
78+
79+
# Get the current hash
80+
result = await get_text_file_contents(
81+
{"files": [{"file_path": str(test_file), "ranges": [{"start": 1}]}]}
82+
)
83+
file_hash = result[str(test_file)]["file_hash"]
84+
85+
# Try to insert text after line 10 (file has only 3 lines)
86+
result = await insert_text_file_contents(
87+
{
88+
"path": str(test_file),
89+
"file_hash": file_hash,
90+
"after": 10,
91+
"contents": "new_line\n",
92+
}
93+
)
94+
95+
assert result["result"] == "error"
96+
assert "beyond end of file" in result["reason"]
97+
98+
99+
@pytest.mark.asyncio
100+
async def test_file_not_found(tmp_path: Path) -> None:
101+
"""Test inserting text into a non-existent file."""
102+
# Try to insert text into a non-existent file
103+
result = await insert_text_file_contents(
104+
{
105+
"path": str(tmp_path / "nonexistent.txt"),
106+
"file_hash": "any_hash",
107+
"after": 1,
108+
"contents": "new_line\n",
109+
}
110+
)
111+
112+
assert result["result"] == "error"
113+
assert "File not found" in result["reason"]
114+
115+
116+
@pytest.mark.asyncio
117+
async def test_hash_mismatch(tmp_path: Path) -> None:
118+
"""Test inserting text with incorrect file hash."""
119+
# Create a test file
120+
test_file = tmp_path / "test.txt"
121+
test_file.write_text("line1\nline2\nline3\n")
122+
123+
# Try to insert text with incorrect hash
124+
result = await insert_text_file_contents(
125+
{
126+
"path": str(test_file),
127+
"file_hash": "incorrect_hash",
128+
"after": 1,
129+
"contents": "new_line\n",
130+
}
131+
)
132+
133+
assert result["result"] == "error"
134+
assert "hash mismatch" in result["reason"]

tests/test_patch_text_file.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Test module for patch_text_file."""
2+
3+
import os
4+
5+
import pytest
6+
7+
from mcp_text_editor.text_editor import TextEditor
8+
9+
10+
@pytest.mark.asyncio
11+
async def test_patch_text_file_middle(tmp_path):
12+
"""Test patching text in the middle of the file."""
13+
# Create a test file
14+
file_path = os.path.join(tmp_path, "test.txt")
15+
editor = TextEditor()
16+
17+
# Create initial content
18+
content = "line1\nline2\nline3\nline4\nline5\n"
19+
with open(file_path, "w", encoding="utf-8") as f:
20+
f.write(content)
21+
22+
# Get file hash and range hash
23+
file_info = await editor.read_multiple_ranges(
24+
[{"file_path": str(file_path), "ranges": [{"start": 2, "end": 3}]}]
25+
)
26+
27+
# Extract file and range hashes
28+
file_content = file_info[str(file_path)]
29+
file_hash = file_content["file_hash"]
30+
range_hash = file_content["ranges"][0]["range_hash"]
31+
32+
# Patch the file
33+
new_content = "new line2\nnew line3\n"
34+
patch = {
35+
"start": 2,
36+
"end": 3, # changed from 4 to 3 since we only want to replace lines 2-3
37+
"contents": new_content,
38+
"range_hash": range_hash,
39+
}
40+
result = await editor.edit_file_contents(
41+
str(file_path),
42+
file_hash,
43+
[patch],
44+
)
45+
46+
# Verify the patch was successful
47+
assert result["result"] == "ok"
48+
with open(file_path, "r", encoding="utf-8") as f:
49+
updated_content = f.read()
50+
assert updated_content == "line1\nnew line2\nnew line3\nline4\nline5\n"

0 commit comments

Comments
 (0)