Skip to content

Commit 2b6912c

Browse files
committed
feat(docs): update README to include new feature for reading multiple ranges from multiple files
feat(server.py): modify GetTextFileContentsHandler to support reading multiple ranges from multiple files feat(text_editor.py): implement read_multiple_ranges method to handle multiple file and line range requests fix(docs): correct description and parameters for get_text_file_contents API endpoint to reflect new functionality fix(docs): update edit_text_file_contents API endpoint to clarify usage and response structure
1 parent d2167d6 commit 2b6912c

File tree

3 files changed

+336
-67
lines changed

3 files changed

+336
-67
lines changed

README.md

Lines changed: 180 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ A Model Context Protocol (MCP) server that provides text file editing capabiliti
55
## Features
66

77
- Get text file contents with line range specification
8+
- Read multiple ranges from multiple files in a single operation
89
- Edit text file contents with conflict detection
910
- Support for multiple file operations
1011
- Proper handling of concurrent edits with hash-based validation
@@ -55,74 +56,221 @@ Start the server:
5556
python -m mcp_text_editor
5657
```
5758

58-
### API Endpoints
59+
### MCP Tools
5960

60-
#### GetTextFileContents
61+
The server provides two main tools:
6162

62-
Get the contents of a text file within a specified line range.
63+
#### get_text_file_contents
6364

64-
**Parameters:**
65-
- `file_path`: (required) Path to the text file
66-
- `line_start`: (optional, default: 1) Starting line number
67-
- `line_end`: (optional, default: null) Ending line number
65+
Get the contents of one or more text files with line range specification.
6866

69-
**Returns:**
67+
**Single Range Request:**
68+
69+
```json
70+
{
71+
"file_path": "path/to/file.txt",
72+
"line_start": 1,
73+
"line_end": 10
74+
}
75+
```
76+
77+
**Multiple Ranges Request:**
78+
79+
```json
80+
{
81+
"files": [
82+
{
83+
"file_path": "file1.txt",
84+
"ranges": [
85+
{"start": 1, "end": 10},
86+
{"start": 20, "end": 30}
87+
]
88+
},
89+
{
90+
"file_path": "file2.txt",
91+
"ranges": [
92+
{"start": 5, "end": 15}
93+
]
94+
}
95+
]
96+
}
97+
```
98+
99+
Parameters:
100+
- `file_path`: Path to the text file
101+
- `line_start`/`start`: Line number to start from (1-based)
102+
- `line_end`/`end`: Line number to end at (inclusive, null for end of file)
103+
104+
**Single Range Response:**
70105

71106
```json
72107
{
73108
"contents": "File contents",
74109
"line_start": 1,
75-
"line_end": 5,
76-
"hash": "sha256-hash-of-contents"
110+
"line_end": 10,
111+
"hash": "sha256-hash-of-contents",
112+
"file_lines": 50,
113+
"file_size": 1024
77114
}
78115
```
79116

80-
#### EditTextFileContents
117+
**Multiple Ranges Response:**
81118

82-
Edit text file contents with conflict detection. Can handle multiple files and multiple patches per file.
83-
Patches are always applied from bottom to top to handle line number shifts correctly.
119+
```json
120+
{
121+
"file1.txt": [
122+
{
123+
"content": "Lines 1-10 content",
124+
"start_line": 1,
125+
"end_line": 10,
126+
"hash": "sha256-hash-1",
127+
"total_lines": 50,
128+
"content_size": 512
129+
},
130+
{
131+
"content": "Lines 20-30 content",
132+
"start_line": 20,
133+
"end_line": 30,
134+
"hash": "sha256-hash-2",
135+
"total_lines": 50,
136+
"content_size": 512
137+
}
138+
],
139+
"file2.txt": [
140+
{
141+
"content": "Lines 5-15 content",
142+
"start_line": 5,
143+
"end_line": 15,
144+
"hash": "sha256-hash-3",
145+
"total_lines": 30,
146+
"content_size": 256
147+
}
148+
]
149+
}
150+
```
84151

85-
**Parameters:**
152+
#### edit_text_file_contents
153+
154+
Edit text file contents with conflict detection. Supports editing multiple files in a single operation.
155+
156+
**Request Format:**
86157

87158
```json
88159
{
89-
"file_path": {
90-
"hash": "sha256-hash-of-original-contents",
91-
"patches": [
92-
{
93-
"line_start": 1,
94-
"line_end": null,
95-
"contents": "New content"
96-
}
97-
]
98-
}
160+
"files": [
161+
{
162+
"path": "file1.txt",
163+
"hash": "sha256-hash-from-get-contents",
164+
"patches": [
165+
{
166+
"line_start": 5,
167+
"line_end": 8,
168+
"contents": "New content for lines 5-8\n"
169+
},
170+
{
171+
"line_start": 15,
172+
"line_end": 15,
173+
"contents": "Single line replacement\n"
174+
}
175+
]
176+
},
177+
{
178+
"path": "file2.txt",
179+
"hash": "sha256-hash-from-get-contents",
180+
"patches": [
181+
{
182+
"line_start": 1,
183+
"line_end": 3,
184+
"contents": "Replace first three lines\n"
185+
}
186+
]
187+
}
188+
]
99189
}
100190
```
101191

102-
**Returns:**
192+
Important Notes:
193+
1. Always get the current hash using get_text_file_contents before editing
194+
2. Patches are applied from bottom to top to handle line number shifts correctly
195+
3. Patches must not overlap within the same file
196+
4. Line numbers are 1-based
197+
5. If original content ends with newline, ensure patch content also ends with newline
198+
199+
**Success Response:**
103200

104201
```json
105202
{
106-
"<file path>": {
203+
"file1.txt": {
204+
"result": "ok",
205+
"hash": "sha256-hash-of-new-contents"
206+
},
207+
"file2.txt": {
107208
"result": "ok",
108209
"hash": "sha256-hash-of-new-contents"
109210
}
110211
}
111212
```
112213

113-
For error cases:
214+
**Error Response:**
114215

115216
```json
116217
{
117-
"<file path>": {
218+
"file1.txt": {
118219
"result": "error",
119-
"reason": "Error message",
220+
"reason": "File not found",
221+
"hash": null
222+
},
223+
"file2.txt": {
224+
"result": "error",
225+
"reason": "Content hash mismatch - file was modified",
120226
"hash": "current-hash",
121-
"content": "Current content (if hash mismatch)"
227+
"content": "Current file content"
122228
}
123229
}
124230
```
125231

232+
### Common Usage Pattern
233+
234+
1. Get current content and hash:
235+
```python
236+
contents = await get_text_file_contents({
237+
"files": [
238+
{
239+
"file_path": "file.txt",
240+
"ranges": [{"start": 1, "end": null}] # Read entire file
241+
}
242+
]
243+
})
244+
```
245+
246+
2. Edit file content:
247+
```python
248+
result = await edit_text_file_contents({
249+
"files": [
250+
{
251+
"path": "file.txt",
252+
"hash": contents["file.txt"][0]["hash"],
253+
"patches": [
254+
{
255+
"line_start": 5,
256+
"line_end": 8,
257+
"contents": "New content\n"
258+
}
259+
]
260+
}
261+
]
262+
})
263+
```
264+
265+
3. Handle conflicts:
266+
```python
267+
if result["file.txt"]["result"] == "error":
268+
if "hash mismatch" in result["file.txt"]["reason"]:
269+
# File was modified by another process
270+
# Get new content and retry
271+
pass
272+
```
273+
126274
### Error Handling
127275

128276
The server handles various error cases:
@@ -165,7 +313,7 @@ pytest --cov=mcp_text_editor --cov-report=term-missing
165313
pytest tests/test_text_editor.py -v
166314
```
167315

168-
Current test coverage: 88%
316+
Current test coverage: 90%
169317

170318
### Project Structure
171319

@@ -208,4 +356,4 @@ New features should include appropriate tests. Try to maintain or improve the cu
208356

209357
### Code Style
210358

211-
All code should be formatted with Black and pass Ruff linting. Import sorting should be handled by isort.
359+
All code should be formatted with Black and pass Ruff linting. Import sorting should be handled by isort.

src/mcp_text_editor/server.py

Lines changed: 65 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class GetTextFileContentsHandler:
2323
"""Handler for getting text file contents."""
2424

2525
name = "get_text_file_contents"
26-
description = "Read text file contents within a specified line range. Returns file content with a hash for concurrency control and line numbers for reference.The hash is used to detect conflicts when editing the file."
26+
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."
2727

2828
def __init__(self):
2929
self.editor = TextEditor()
@@ -36,47 +36,79 @@ def get_tool_description(self) -> Tool:
3636
inputSchema={
3737
"type": "object",
3838
"properties": {
39-
"file_path": {
40-
"type": "string",
41-
"description": "Path to the text file",
42-
},
43-
"line_start": {
44-
"type": "integer",
45-
"description": "Starting line number (1-based)",
46-
"default": 1,
47-
},
48-
"line_end": {
49-
"type": ["integer", "null"],
50-
"description": "Ending line number (null for end of file)",
51-
"default": None,
52-
},
39+
"files": {
40+
"type": "array",
41+
"description": "List of files and their line ranges to read",
42+
"items": {
43+
"type": "object",
44+
"properties": {
45+
"file_path": {
46+
"type": "string",
47+
"description": "Path to the text file",
48+
},
49+
"ranges": {
50+
"type": "array",
51+
"description": "List of line ranges to read from the file",
52+
"items": {
53+
"type": "object",
54+
"properties": {
55+
"start": {
56+
"type": "integer",
57+
"description": "Starting line number (1-based)",
58+
},
59+
"end": {
60+
"type": ["integer", "null"],
61+
"description": "Ending line number (null for end of file)",
62+
},
63+
},
64+
"required": ["start"],
65+
},
66+
},
67+
},
68+
"required": ["file_path", "ranges"],
69+
},
70+
}
5371
},
54-
"required": ["file_path"],
72+
"required": ["files"],
5573
},
5674
)
5775

5876
async def run_tool(self, arguments: Dict[str, Any]) -> Sequence[TextContent]:
5977
"""Execute the tool with given arguments."""
6078
try:
61-
file_path = arguments["file_path"]
62-
line_start = arguments.get("line_start", 1)
63-
line_end = arguments.get("line_end")
79+
if "files" not in arguments:
80+
# Handle legacy single file request
81+
if "file_path" in arguments:
82+
file_path = arguments["file_path"]
83+
line_start = arguments.get("line_start", 1)
84+
line_end = arguments.get("line_end")
85+
86+
content, start, end, content_hash, file_lines, file_size = (
87+
await self.editor.read_file_contents(
88+
file_path, line_start, line_end
89+
)
90+
)
6491

65-
content, start, end, content_hash, file_lines, file_size = (
66-
await self.editor.read_file_contents(file_path, line_start, line_end)
67-
)
92+
response = {
93+
"contents": content,
94+
"line_start": start,
95+
"line_end": end,
96+
"hash": content_hash,
97+
"file_path": file_path,
98+
"file_lines": file_lines,
99+
"file_size": file_size,
100+
}
101+
102+
return [
103+
TextContent(type="text", text=json.dumps(response, indent=2))
104+
]
105+
else:
106+
raise RuntimeError("Missing required argument: files")
107+
108+
# Handle multi-file request
109+
result = await self.editor.read_multiple_ranges(arguments["files"])
110+
return [TextContent(type="text", text=json.dumps(result, indent=2))]
68111

69-
response = {
70-
"contents": content,
71-
"line_start": start,
72-
"line_end": end,
73-
"hash": content_hash,
74-
"file_path": file_path,
75-
"file_lines": file_lines,
76-
"file_size": file_size,
77-
}
78-
79-
return [TextContent(type="text", text=json.dumps(response, indent=2))]
80112
except KeyError as e:
81113
raise RuntimeError(f"Missing required argument: {e}") from e
82114
except Exception as e:

0 commit comments

Comments
 (0)