Skip to content

Commit c5d2298

Browse files
committed
Merge branch 'develop'
2 parents 0f5f220 + 1e30fe3 commit c5d2298

File tree

13 files changed

+1551
-678
lines changed

13 files changed

+1551
-678
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@ typecheck:
2424
# Run all checks required before pushing
2525
check: lint typecheck test
2626
fix: check format
27-
all: format check
27+
all: format check coverage

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ MCP Text Editor Server is designed to facilitate safe and efficient line-based t
4747
- Read multiple ranges from multiple files in a single operation
4848
- Line-based patch application with correct handling of line number shifts
4949
- Edit text file contents with conflict detection
50+
- Flexible character encoding support (utf-8, shift_jis, latin1, etc.)
5051
- Support for multiple file operations
5152
- Proper handling of concurrent edits with hash-based validation
5253
- Memory-efficient processing of large files
@@ -143,6 +144,7 @@ Parameters:
143144
- `file_path`: Path to the text file
144145
- `line_start`/`start`: Line number to start from (1-based)
145146
- `line_end`/`end`: Line number to end at (inclusive, null for end of file)
147+
- `encoding`: File encoding (default: "utf-8"). Specify the encoding of the text file (e.g., "shift_jis", "latin1")
146148

147149
**Single Range Response:**
148150

@@ -238,6 +240,7 @@ Important Notes:
238240
3. Patches must not overlap within the same file
239241
4. Line numbers are 1-based
240242
5. If original content ends with newline, ensure patch content also ends with newline
243+
6. File encoding must match the encoding used in get_text_file_contents
241244

242245
**Success Response:**
243246

@@ -295,6 +298,7 @@ result = await edit_text_file_contents({
295298
{
296299
"path": "file.txt",
297300
"hash": contents["file.txt"][0]["hash"],
301+
"encoding": "utf-8", # Optional, defaults to "utf-8"
298302
"patches": [
299303
{
300304
"line_start": 5,
@@ -325,6 +329,7 @@ The server handles various error cases:
325329
- Hash mismatches (concurrent edit detection)
326330
- Invalid patch ranges
327331
- Overlapping patches
332+
- Encoding errors (when file cannot be decoded with specified encoding)
328333
- Line number out of bounds
329334

330335
## Security Considerations

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ authors = [
77
]
88
dependencies = [
99
"asyncio>=3.4.3",
10-
"mcp>=1.1.0",
10+
"mcp>=1.1.2",
11+
"chardet>=5.2.0",
1112
]
1213
requires-python = ">=3.11"
1314
readme = "README.md"

src/mcp_text_editor/models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class EditPatch(BaseModel):
2828
line_start: int = Field(1, description="Starting line for edit")
2929
line_end: Optional[int] = Field(None, description="Ending line for edit")
3030
contents: str = Field(..., description="New content to insert")
31+
range_hash: Optional[str] = Field(
32+
None, description="Hash of content being replaced. None for insertions"
33+
)
3134

3235

3336
class EditFileOperation(BaseModel):
@@ -80,3 +83,21 @@ class EditTextFileContentsRequest(BaseModel):
8083
"""
8184

8285
files: List[EditFileOperation] = Field(..., description="List of file operations")
86+
87+
88+
class FileRange(BaseModel):
89+
"""Represents a line range in a file."""
90+
91+
start: int = Field(..., description="Starting line number (1-based)")
92+
end: Optional[int] = Field(
93+
None, description="Ending line number (null for end of file)"
94+
)
95+
96+
97+
class FileRanges(BaseModel):
98+
"""Represents a file and its line ranges."""
99+
100+
file_path: str = Field(..., description="Path to the text file")
101+
ranges: List[FileRange] = Field(
102+
..., description="List of line ranges to read from the file"
103+
)

src/mcp_text_editor/server.py

Lines changed: 81 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class GetTextFileContentsHandler:
2424
"""Handler for getting text file contents."""
2525

2626
name = "get_text_file_contents"
27-
description = "Read text file contents from multiple files and line ranges. Returns file contents with hashes for concurrency control and line numbers for reference. The hashes are used to detect conflicts when editing the files."
27+
description = "Read text file contents from multiple files and line ranges. Returns file contents with hashes for concurrency control and line numbers for reference. The hashes are used to detect conflicts when editing the files. File paths must be absolute."
2828

2929
def __init__(self):
3030
self.editor = TextEditor()
@@ -45,7 +45,7 @@ def get_tool_description(self) -> Tool:
4545
"properties": {
4646
"file_path": {
4747
"type": "string",
48-
"description": "Path to the text file",
48+
"description": "Path to the text file. File path must be absolute.",
4949
},
5050
"ranges": {
5151
"type": "array",
@@ -68,7 +68,12 @@ def get_tool_description(self) -> Tool:
6868
},
6969
"required": ["file_path", "ranges"],
7070
},
71-
}
71+
},
72+
"encoding": {
73+
"type": "string",
74+
"description": "Text encoding (default: 'utf-8')",
75+
"default": "utf-8",
76+
},
7277
},
7378
"required": ["files"],
7479
},
@@ -78,40 +83,24 @@ async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
7883
"""Execute the tool with given arguments."""
7984
try:
8085
if "files" not in arguments:
81-
# Handle legacy single file request
82-
if "file_path" in arguments:
83-
file_path = arguments["file_path"]
84-
line_start = arguments.get("line_start", 1)
85-
line_end = arguments.get("line_end")
86-
87-
content, start, end, content_hash, file_lines, file_size = (
88-
await self.editor.read_file_contents(
89-
file_path, line_start, line_end
90-
)
91-
)
86+
raise RuntimeError("Missing required argument: 'files'")
9287

93-
response = {
94-
"contents": content,
95-
"line_start": start,
96-
"line_end": end,
97-
"hash": content_hash,
98-
"file_path": file_path,
99-
"file_lines": file_lines,
100-
"file_size": file_size,
101-
}
88+
for file_info in arguments["files"]:
89+
if not os.path.isabs(file_info["file_path"]):
90+
raise RuntimeError(
91+
f"File path must be absolute: {file_info['file_path']}"
92+
)
10293

103-
return [
104-
TextContent(type="text", text=json.dumps(response, indent=2))
105-
]
106-
else:
107-
raise RuntimeError("Missing required argument: files")
94+
encoding = arguments.get("encoding", "utf-8")
95+
result = await self.editor.read_multiple_ranges(
96+
arguments["files"], encoding=encoding
97+
)
98+
response = result
10899

109-
# Handle multi-file request
110-
result = await self.editor.read_multiple_ranges(arguments["files"])
111-
return [TextContent(type="text", text=json.dumps(result, indent=2))]
100+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
112101

113102
except KeyError as e:
114-
raise RuntimeError(f"Missing required argument: {e}") from e
103+
raise RuntimeError(f"Missing required argument: '{e}'") from e
115104
except Exception as e:
116105
raise RuntimeError(f"Error processing request: {str(e)}") from e
117106

@@ -120,7 +109,7 @@ class EditTextFileContentsHandler:
120109
"""Handler for editing text file contents."""
121110

122111
name = "edit_text_file_contents"
123-
description = "A line editor that supports editing text file contents by specifying line ranges and content. It handles multiple patches in a single operation with hash-based conflict detection. IMPORTANT: (1) Before using this tool, you must first get the file's current hash using get_text_file_contents. (2) To avoid line number shifts affecting your patches, use get_text_file_contents to read the same ranges you plan to edit before making changes. (3) Patches must be specified from bottom to top to handle line number shifts correctly, as edits to lower lines don't affect the line numbers of higher lines."
112+
description = "A line editor that supports editing text file contents by specifying line ranges and content. It handles multiple patches in a single operation with hash-based conflict detection. File paths must be absolute. IMPORTANT: (1) Before using this tool, you must first get the file's current hash and range hashes and line numbers using get_text_file_contents. (2) To avoid line number shifts affecting your patches, use get_text_file_contents to read the SAME ranges you plan to edit before making changes. different line numbers have different rangehashes.(3) Patches must be specified from bottom to top to handle line number shifts correctly, as edits to lower lines don't affect the line numbers of higher lines. (4) To append content to a file, first get the total number of lines with get_text_file_contents, then specify a patch with line_start = total_lines + 1 and line_end = total_lines. This indicates an append operation and range_hash is not required. Similarly, range_hash is not required for new file creation."
124113

125114
def __init__(self):
126115
self.editor = TextEditor()
@@ -138,8 +127,14 @@ def get_tool_description(self) -> Tool:
138127
"items": {
139128
"type": "object",
140129
"properties": {
141-
"path": {"type": "string"},
142-
"hash": {"type": "string"},
130+
"path": {
131+
"type": "string",
132+
"description": "Path to the text file. File path must be absolute.",
133+
},
134+
"file_hash": {
135+
"type": "string",
136+
"description": "Hash of the file contents when get_text_file_contents is called.",
137+
},
143138
"patches": {
144139
"type": "array",
145140
"items": {
@@ -148,20 +143,31 @@ def get_tool_description(self) -> Tool:
148143
"line_start": {
149144
"type": "integer",
150145
"default": 1,
146+
"description": "Starting line number (1-based). it should be matched with the start line number when get_text_file_contents is called.",
151147
},
152148
"line_end": {
153149
"type": ["integer", "null"],
154150
"default": None,
151+
"description": "Ending line number (null for end of file). it should be matched with the end line number when get_text_file_contents is called.",
155152
},
156153
"contents": {"type": "string"},
154+
"range_hash": {
155+
"type": "string",
156+
"description": "Hash of the content being replaced from line_start to line_end (required except for new files and append operations)",
157+
},
157158
},
158159
"required": ["contents"],
159160
},
160161
},
161162
},
162-
"required": ["path", "hash", "patches"],
163+
"required": ["path", "file_hash", "patches"],
163164
},
164-
}
165+
},
166+
"encoding": {
167+
"type": "string",
168+
"description": "Text encoding (default: 'utf-8')",
169+
"default": "utf-8",
170+
},
165171
},
166172
"required": ["files"],
167173
},
@@ -176,61 +182,58 @@ async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
176182
files = arguments["files"]
177183
results: Dict[str, Dict] = {}
178184

179-
for file_operation in files:
180-
file_path = None
181-
try:
182-
try:
183-
file_path = file_operation["path"]
184-
except KeyError as e:
185-
raise RuntimeError(
186-
"Missing required field: path in file operation"
187-
) from e
185+
if len(files) == 0:
186+
return [TextContent(type="text", text=json.dumps(results, indent=2))]
188187

189-
# Ensure the file exists
190-
if not os.path.exists(file_path):
191-
results[file_path] = {
192-
"result": "error",
193-
"reason": "File not found",
194-
"hash": None,
195-
}
196-
continue
188+
for file_operation in files:
189+
# First check if required fields exist
190+
if "path" not in file_operation:
191+
raise RuntimeError("Missing required field: path")
192+
if "file_hash" not in file_operation:
193+
raise RuntimeError("Missing required field: file_hash")
194+
if "patches" not in file_operation:
195+
raise RuntimeError("Missing required field: patches")
196+
197+
# Then check if path is absolute
198+
if not os.path.isabs(file_operation["path"]):
199+
raise RuntimeError(
200+
f"File path must be absolute: {file_operation['path']}"
201+
)
197202

198-
try:
199-
file_hash = file_operation["hash"]
200-
except KeyError as e:
201-
raise RuntimeError(
202-
f"Missing required field: hash for file {file_path}"
203-
) from e
204-
205-
# Ensure patches list is not empty
206-
try:
207-
patches = file_operation["patches"]
208-
except KeyError as e:
209-
raise RuntimeError(
210-
f"Missing required field: patches for file {file_path}"
211-
) from e
203+
try:
204+
file_path = file_operation["path"]
205+
file_hash = file_operation["file_hash"]
206+
patches = file_operation["patches"]
212207

213208
if not patches:
214209
results[file_path] = {
215210
"result": "error",
216211
"reason": "Empty patches list",
217-
"hash": None,
212+
"file_hash": file_hash,
218213
}
219214
continue
220215

216+
encoding = arguments.get("encoding", "utf-8")
221217
result = await self.editor.edit_file_contents(
222-
file_path, file_hash, patches
218+
file_path, file_hash, patches, encoding=encoding
223219
)
224220
results[file_path] = result
225221
except Exception as e:
226-
if file_path:
227-
results[file_path] = {
228-
"result": "error",
229-
"reason": str(e),
230-
"hash": None,
231-
}
232-
else:
233-
raise
222+
current_hash = None
223+
if "path" in file_operation:
224+
file_path = file_operation["path"]
225+
try:
226+
_, _, _, current_hash, _, _ = (
227+
await self.editor.read_file_contents(file_path)
228+
)
229+
except Exception:
230+
current_hash = None
231+
232+
results[file_path if "path" in file_operation else "unknown"] = {
233+
"result": "error",
234+
"reason": str(e),
235+
"file_hash": current_hash,
236+
}
234237

235238
return [TextContent(type="text", text=json.dumps(results, indent=2))]
236239
except Exception as e:

src/mcp_text_editor/service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ def edit_file_contents(
120120
file_path: EditResult(
121121
result="error",
122122
reason=str(e),
123-
hash=current_hash,
123+
hash=None, # Don't return the current hash on error
124124
content=None,
125125
)
126126
}

0 commit comments

Comments
 (0)