Skip to content

Commit a34614c

Browse files
author
Yoshihiro Takahara
committed
feat: add create_text_file tool
Add new tool for creating new text files with validation: - Check if file already exists - Validate absolute path - Support custom encoding - Add comprehensive test cases
1 parent 6d917f5 commit a34614c

File tree

3 files changed

+204
-1
lines changed

3 files changed

+204
-1
lines changed

src/mcp_text_editor/server.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,87 @@ async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
241241
logger.error(traceback.format_exc())
242242
raise RuntimeError(f"Error processing request: {str(e)}") from e
243243

244+
class CreateTextFileHandler:
245+
"""Handler for creating a new text file."""
246+
247+
name = "create_text_file"
248+
description = "Create a new text file with given content. The file must not exist already."
249+
250+
def __init__(self):
251+
self.editor = TextEditor()
252+
253+
def get_tool_description(self) -> Tool:
254+
"""Get the tool description."""
255+
return Tool(
256+
name=self.name,
257+
description=self.description,
258+
inputSchema={
259+
"type": "object",
260+
"properties": {
261+
"path": {
262+
"type": "string",
263+
"description": "Path to the text file. File path must be absolute.",
264+
},
265+
"contents": {
266+
"type": "string",
267+
"description": "Content to write to the file",
268+
},
269+
"encoding": {
270+
"type": "string",
271+
"description": "Text encoding (default: 'utf-8')",
272+
"default": "utf-8",
273+
},
274+
},
275+
"required": ["path", "contents"],
276+
},
277+
)
278+
279+
async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
280+
"""Execute the tool with given arguments."""
281+
try:
282+
if "path" not in arguments:
283+
raise RuntimeError("Missing required argument: path")
284+
if "contents" not in arguments:
285+
raise RuntimeError("Missing required argument: contents")
286+
287+
file_path = arguments["path"]
288+
if not os.path.isabs(file_path):
289+
raise RuntimeError(f"File path must be absolute: {file_path}")
290+
291+
# Check if file already exists
292+
if os.path.exists(file_path):
293+
raise RuntimeError(f"File already exists: {file_path}")
294+
295+
encoding = arguments.get("encoding", "utf-8")
296+
297+
# Create new file using edit_file_contents with empty expected_hash
298+
result = await self.editor.edit_file_contents(
299+
file_path,
300+
expected_hash="", # Empty hash for new file
301+
patches=[
302+
{
303+
"start": 1,
304+
"end": None,
305+
"contents": arguments["contents"],
306+
"range_hash": "", # Empty range_hash for new file
307+
}
308+
],
309+
encoding=encoding,
310+
)
311+
312+
return [TextContent(type="text", text=json.dumps(result, indent=2))]
313+
314+
except Exception as e:
315+
logger.error(f"Error processing request: {str(e)}")
316+
logger.error(traceback.format_exc())
317+
raise RuntimeError(f"Error processing request: {str(e)}") from e
318+
319+
244320

245321
# Initialize tool handlers
246322
get_contents_handler = GetTextFileContentsHandler()
247323
edit_contents_handler = EditTextFileContentsHandler()
324+
create_file_handler = CreateTextFileHandler()
248325

249326

250327
@app.list_tools()
@@ -253,6 +330,7 @@ async def list_tools() -> List[Tool]:
253330
return [
254331
get_contents_handler.get_tool_description(),
255332
edit_contents_handler.get_tool_description(),
333+
create_file_handler.get_tool_description(),
256334
]
257335

258336

@@ -265,6 +343,8 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
265343
return await get_contents_handler.run_tool(arguments)
266344
elif name == edit_contents_handler.name:
267345
return await edit_contents_handler.run_tool(arguments)
346+
elif name == create_file_handler.name:
347+
return await create_file_handler.run_tool(arguments)
268348
else:
269349
raise ValueError(f"Unknown tool: {name}")
270350
except ValueError:

tests/test_create_text_file.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Test cases for create_text_file handler."""
2+
3+
import os
4+
from typing import Any, Dict
5+
6+
import pytest
7+
8+
from mcp_text_editor.server import CreateTextFileHandler
9+
from mcp_text_editor.text_editor import TextEditor
10+
11+
# Initialize handlers for tests
12+
create_file_handler = CreateTextFileHandler()
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_create_text_file_success(test_dir: str, cleanup_files: None) -> None:
30+
"""Test successful creation of a new text file."""
31+
test_file = os.path.join(test_dir, "new_file.txt")
32+
content = "Hello, World!\n"
33+
34+
# Create file using handler
35+
arguments: Dict[str, Any] = {
36+
"path": test_file,
37+
"contents": content,
38+
}
39+
response = await create_file_handler.run_tool(arguments)
40+
41+
# Check if file was created with correct content
42+
assert os.path.exists(test_file)
43+
with open(test_file, "r", encoding="utf-8") as f:
44+
assert f.read() == content
45+
46+
# Parse response to check success
47+
assert len(response) == 1
48+
result = response[0].text
49+
assert "\"result\": \"ok\"" in result
50+
51+
52+
@pytest.mark.asyncio
53+
async def test_create_text_file_exists(test_dir: str, cleanup_files: None) -> None:
54+
"""Test attempting to create a file that already exists."""
55+
test_file = os.path.join(test_dir, "existing_file.txt")
56+
57+
# Create file first
58+
with open(test_file, "w", encoding="utf-8") as f:
59+
f.write("Existing content\n")
60+
61+
# Try to create file using handler
62+
arguments: Dict[str, Any] = {
63+
"path": test_file,
64+
"contents": "New content\n",
65+
}
66+
67+
# Should raise error because file exists
68+
with pytest.raises(RuntimeError):
69+
await create_file_handler.run_tool(arguments)
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_create_text_file_relative_path(test_dir: str, cleanup_files: None) -> None:
74+
"""Test attempting to create a file with a relative path."""
75+
# Try to create file using relative path
76+
arguments: Dict[str, Any] = {
77+
"path": "relative_path.txt",
78+
"contents": "Some content\n",
79+
}
80+
81+
# Should raise error because path is not absolute
82+
with pytest.raises(RuntimeError) as exc_info:
83+
await create_file_handler.run_tool(arguments)
84+
assert "File path must be absolute" in str(exc_info.value)
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_create_text_file_missing_args() -> None:
89+
"""Test creating a file with missing arguments."""
90+
# Test missing path
91+
with pytest.raises(RuntimeError) as exc_info:
92+
await create_file_handler.run_tool({"contents": "content\n"})
93+
assert "Missing required argument: path" in str(exc_info.value)
94+
95+
# Test missing contents
96+
with pytest.raises(RuntimeError) as exc_info:
97+
await create_file_handler.run_tool({"path": "/absolute/path.txt"})
98+
assert "Missing required argument: contents" in str(exc_info.value)
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_create_text_file_custom_encoding(test_dir: str, cleanup_files: None) -> None:
103+
"""Test creating a file with custom encoding."""
104+
test_file = os.path.join(test_dir, "encoded_file.txt")
105+
content = "こんにちは\n" # Japanese text
106+
107+
# Create file using handler with specified encoding
108+
arguments: Dict[str, Any] = {
109+
"path": test_file,
110+
"contents": content,
111+
"encoding": "utf-8",
112+
}
113+
response = await create_file_handler.run_tool(arguments)
114+
115+
# Check if file was created with correct content
116+
assert os.path.exists(test_file)
117+
with open(test_file, "r", encoding="utf-8") as f:
118+
assert f.read() == content
119+
120+
# Parse response to check success
121+
assert len(response) == 1
122+
result = response[0].text
123+
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) == 2
28+
assert len(tools) == 3
2929

3030
# Verify GetTextFileContents tool
3131
get_contents_tool = next(

0 commit comments

Comments
 (0)