Skip to content

Commit df2d3ce

Browse files
author
Yoshihiro Takahara
committed
feat: add create_text_file and append_text_file_contents tools
- Add create_text_file tool for new file creation - Add append_text_file_contents tool for appending content - Add comprehensive test cases for both tools - Update test_server.py to accommodate new tools
1 parent a34614c commit df2d3ce

File tree

3 files changed

+271
-2
lines changed

3 files changed

+271
-2
lines changed

src/mcp_text_editor/server.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,103 @@ async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
308308
],
309309
encoding=encoding,
310310
)
311+
return [TextContent(type="text", text=json.dumps(result, indent=2))]
312+
313+
except Exception as e:
314+
logger.error(f"Error processing request: {str(e)}")
315+
logger.error(traceback.format_exc())
316+
raise RuntimeError(f"Error processing request: {str(e)}") from e
317+
318+
319+
class AppendTextFileContentsHandler:
320+
"""Handler for appending content to an existing text file."""
321+
322+
name = "append_text_file_contents"
323+
description = "Append content to an existing text file. The file must exist."
324+
325+
def __init__(self):
326+
self.editor = TextEditor()
327+
328+
def get_tool_description(self) -> Tool:
329+
"""Get the tool description."""
330+
return Tool(
331+
name=self.name,
332+
description=self.description,
333+
inputSchema={
334+
"type": "object",
335+
"properties": {
336+
"path": {
337+
"type": "string",
338+
"description": "Path to the text file. File path must be absolute.",
339+
},
340+
"contents": {
341+
"type": "string",
342+
"description": "Content to append to the file",
343+
},
344+
"file_hash": {
345+
"type": "string",
346+
"description": "Hash of the file contents for concurrency control",
347+
},
348+
"encoding": {
349+
"type": "string",
350+
"description": "Text encoding (default: 'utf-8')",
351+
"default": "utf-8",
352+
},
353+
},
354+
"required": ["path", "contents", "file_hash"],
355+
},
356+
)
357+
358+
async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
359+
"""Execute the tool with given arguments."""
360+
try:
361+
if "path" not in arguments:
362+
raise RuntimeError("Missing required argument: path")
363+
if "contents" not in arguments:
364+
raise RuntimeError("Missing required argument: contents")
365+
if "file_hash" not in arguments:
366+
raise RuntimeError("Missing required argument: file_hash")
367+
368+
file_path = arguments["path"]
369+
if not os.path.isabs(file_path):
370+
raise RuntimeError(f"File path must be absolute: {file_path}")
371+
372+
# Check if file exists
373+
if not os.path.exists(file_path):
374+
raise RuntimeError(f"File does not exist: {file_path}")
375+
376+
encoding = arguments.get("encoding", "utf-8")
377+
378+
# Check file contents and hash before modification
379+
# Get file information and verify hash
380+
content, _, _, current_hash, total_lines, _ = await self.editor.read_file_contents(
381+
file_path,
382+
encoding=encoding
383+
)
384+
385+
# Verify file hash
386+
if current_hash != arguments["file_hash"]:
387+
raise RuntimeError("File hash mismatch - file may have been modified")
388+
389+
# Ensure the append content ends with newline
390+
append_content = arguments["contents"]
391+
if not append_content.endswith("\n"):
392+
append_content += "\n"
393+
394+
# Create patch for append operation
395+
result = await self.editor.edit_file_contents(
396+
file_path,
397+
expected_hash=arguments["file_hash"],
398+
patches=[
399+
{
400+
"start": total_lines + 1,
401+
"end": None,
402+
"contents": append_content,
403+
"range_hash": "",
404+
}
405+
],
406+
encoding=encoding,
407+
)
311408

312409
return [TextContent(type="text", text=json.dumps(result, indent=2))]
313410

@@ -322,7 +419,7 @@ async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
322419
get_contents_handler = GetTextFileContentsHandler()
323420
edit_contents_handler = EditTextFileContentsHandler()
324421
create_file_handler = CreateTextFileHandler()
325-
422+
append_file_handler = AppendTextFileContentsHandler()
326423

327424
@app.list_tools()
328425
async def list_tools() -> List[Tool]:
@@ -331,6 +428,7 @@ async def list_tools() -> List[Tool]:
331428
get_contents_handler.get_tool_description(),
332429
edit_contents_handler.get_tool_description(),
333430
create_file_handler.get_tool_description(),
431+
append_file_handler.get_tool_description(),
334432
]
335433

336434

@@ -345,6 +443,8 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
345443
return await edit_contents_handler.run_tool(arguments)
346444
elif name == create_file_handler.name:
347445
return await create_file_handler.run_tool(arguments)
446+
elif name == append_file_handler.name:
447+
return await append_file_handler.run_tool(arguments)
348448
else:
349449
raise ValueError(f"Unknown tool: {name}")
350450
except ValueError:

tests/test_append_text_file.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Test cases for append_text_file_contents handler."""
2+
3+
import os
4+
from typing import Any, Dict
5+
6+
import pytest
7+
8+
from mcp_text_editor.server import AppendTextFileContentsHandler
9+
from mcp_text_editor.text_editor import TextEditor
10+
11+
# Initialize handler for tests
12+
append_handler = AppendTextFileContentsHandler()
13+
14+
15+
@pytest.fixture
16+
def test_dir(tmp_path: str) -> str:
17+
"""Create a temporary directory for test files."""
18+
return str(tmp_path)
19+
20+
21+
@pytest.fixture
22+
def cleanup_files() -> None:
23+
"""Clean up any test files after each test."""
24+
yield
25+
# Add cleanup code if needed
26+
27+
28+
@pytest.mark.asyncio
29+
async def test_append_text_file_success(test_dir: str, cleanup_files: None) -> None:
30+
"""Test successful appending to a file."""
31+
test_file = os.path.join(test_dir, "append_test.txt")
32+
initial_content = "Initial content\n"
33+
append_content = "Appended content\n"
34+
35+
# Create initial file
36+
with open(test_file, "w", encoding="utf-8") as f:
37+
f.write(initial_content)
38+
39+
# Get file hash for append operation
40+
editor = TextEditor()
41+
_, _, _, file_hash, _, _ = await editor.read_file_contents(test_file)
42+
43+
# Append content using handler
44+
arguments: Dict[str, Any] = {
45+
"path": test_file,
46+
"contents": append_content,
47+
"file_hash": file_hash,
48+
}
49+
response = await append_handler.run_tool(arguments)
50+
51+
# Check if content was appended correctly
52+
with open(test_file, "r", encoding="utf-8") as f:
53+
content = f.read()
54+
assert content == initial_content + append_content
55+
56+
# Parse response to check success
57+
assert len(response) == 1
58+
result = response[0].text
59+
assert "\"result\": \"ok\"" in result
60+
61+
62+
@pytest.mark.asyncio
63+
async def test_append_text_file_not_exists(test_dir: str, cleanup_files: None) -> None:
64+
"""Test attempting to append to a non-existent file."""
65+
test_file = os.path.join(test_dir, "nonexistent.txt")
66+
67+
# Try to append to non-existent file
68+
arguments: Dict[str, Any] = {
69+
"path": test_file,
70+
"contents": "Some content\n",
71+
"file_hash": "dummy_hash",
72+
}
73+
74+
# Should raise error because file doesn't exist
75+
with pytest.raises(RuntimeError) as exc_info:
76+
await append_handler.run_tool(arguments)
77+
assert "File does not exist" in str(exc_info.value)
78+
79+
80+
@pytest.mark.asyncio
81+
async def test_append_text_file_hash_mismatch(test_dir: str, cleanup_files: None) -> None:
82+
"""Test appending with incorrect file hash."""
83+
test_file = os.path.join(test_dir, "hash_test.txt")
84+
initial_content = "Initial content\n"
85+
86+
# Create initial file
87+
with open(test_file, "w", encoding="utf-8") as f:
88+
f.write(initial_content)
89+
90+
# Try to append with incorrect hash
91+
arguments: Dict[str, Any] = {
92+
"path": test_file,
93+
"contents": "New content\n",
94+
"file_hash": "incorrect_hash",
95+
}
96+
97+
# Should raise error because hash doesn't match
98+
with pytest.raises(RuntimeError) as exc_info:
99+
await append_handler.run_tool(arguments)
100+
assert "hash mismatch" in str(exc_info.value).lower()
101+
102+
103+
@pytest.mark.asyncio
104+
async def test_append_text_file_relative_path(test_dir: str, cleanup_files: None) -> None:
105+
"""Test attempting to append using a relative path."""
106+
arguments: Dict[str, Any] = {
107+
"path": "relative_path.txt",
108+
"contents": "Some content\n",
109+
"file_hash": "dummy_hash",
110+
}
111+
112+
# Should raise error because path is not absolute
113+
with pytest.raises(RuntimeError) as exc_info:
114+
await append_handler.run_tool(arguments)
115+
assert "File path must be absolute" in str(exc_info.value)
116+
117+
118+
@pytest.mark.asyncio
119+
async def test_append_text_file_missing_args() -> None:
120+
"""Test appending with missing arguments."""
121+
# Test missing path
122+
with pytest.raises(RuntimeError) as exc_info:
123+
await append_handler.run_tool({"contents": "content\n", "file_hash": "hash"})
124+
assert "Missing required argument: path" in str(exc_info.value)
125+
126+
# Test missing contents
127+
with pytest.raises(RuntimeError) as exc_info:
128+
await append_handler.run_tool({"path": "/absolute/path.txt", "file_hash": "hash"})
129+
assert "Missing required argument: contents" in str(exc_info.value)
130+
131+
# Test missing file_hash
132+
with pytest.raises(RuntimeError) as exc_info:
133+
await append_handler.run_tool({"path": "/absolute/path.txt", "contents": "content\n"})
134+
assert "Missing required argument: file_hash" in str(exc_info.value)
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_append_text_file_custom_encoding(test_dir: str, cleanup_files: None) -> None:
139+
"""Test appending with custom encoding."""
140+
test_file = os.path.join(test_dir, "encode_test.txt")
141+
initial_content = "こんにちは\n"
142+
append_content = "さようなら\n"
143+
144+
# Create initial file
145+
with open(test_file, "w", encoding="utf-8") as f:
146+
f.write(initial_content)
147+
148+
# Get file hash for append operation
149+
editor = TextEditor()
150+
_, _, _, file_hash, _, _ = await editor.read_file_contents(test_file, encoding="utf-8")
151+
152+
# Append content using handler with specified encoding
153+
arguments: Dict[str, Any] = {
154+
"path": test_file,
155+
"contents": append_content,
156+
"file_hash": file_hash,
157+
"encoding": "utf-8",
158+
}
159+
response = await append_handler.run_tool(arguments)
160+
161+
# Check if content was appended correctly
162+
with open(test_file, "r", encoding="utf-8") as f:
163+
content = f.read()
164+
assert content == initial_content + append_content
165+
166+
# Parse response to check success
167+
assert len(response) == 1
168+
result = response[0].text
169+
assert "\"result\": \"ok\"" in result

tests/test_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
async def test_list_tools():
2626
"""Test tool listing."""
2727
tools: List[Tool] = await list_tools()
28-
assert len(tools) == 3
28+
assert len(tools) == 4
2929

3030
# Verify GetTextFileContents tool
3131
get_contents_tool = next(

0 commit comments

Comments
 (0)