Skip to content

Commit df1cc8c

Browse files
committed
Update dependencies and enhance patch file functionality with improved search-replace block parsing and error handling.
1 parent 4d181ce commit df1cc8c

File tree

3 files changed

+495
-53
lines changed

3 files changed

+495
-53
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ readme = "README.md"
77
requires-python = ">=3.11"
88
license = { text = "MIT" }
99

10-
dependencies = ["fastmcp>=2.2.0, <3.0.0", "patch-ng>=1.18.0, <2.0.0"]
10+
dependencies = ["fastmcp>=2.2.0, <3.0.0"]
1111

1212
[project.scripts]
1313
patch-file-mcp = "patch_file_mcp.server:main"

src/patch_file_mcp/server.py

100755100644
Lines changed: 165 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,43 @@
22
import sys
33
import argparse
44
from pathlib import Path
5+
import re
56

67
from fastmcp import FastMCP
78
from pydantic.fields import Field
8-
import patch_ng
99

1010

1111
mcp = FastMCP(
1212
name="Patch File MCP",
1313
instructions=f"""
14-
This MCP is for patching existing files.
15-
This can be used to patch files in projects, if project is specified, and the full path to the project
16-
is provided. This should be used most of the time instead of `edit_block` tool from `desktop-commander`.
17-
It can be used to patch multiple parts of the same file.
14+
This MCP is for patching existing files using block format.
15+
16+
Use the block format with SEARCH/REPLACE markers:
17+
```
18+
<<<<<<< SEARCH
19+
Text to find in the file
20+
=======
21+
Text to replace it with
22+
>>>>>>> REPLACE
23+
```
24+
25+
You can include multiple search-replace blocks in a single request:
26+
```
27+
<<<<<<< SEARCH
28+
First text to find
29+
=======
30+
First replacement
31+
>>>>>>> REPLACE
32+
<<<<<<< SEARCH
33+
Second text to find
34+
=======
35+
Second replacement
36+
>>>>>>> REPLACE
37+
```
38+
39+
This tool verifies that each search text appears exactly once in the file to ensure
40+
the correct section is modified. If a search text appears multiple times or isn't
41+
found, it will report an error.
1842
"""
1943
)
2044

@@ -28,7 +52,7 @@ def eprint(*args, **kwargs):
2852
def main():
2953
# Process command line arguments
3054
global allowed_directories
31-
parser = argparse.ArgumentParser(description="Project Memory MCP server")
55+
parser = argparse.ArgumentParser(description="Patch File MCP server")
3256
parser.add_argument(
3357
'--allowed-dir',
3458
action='append',
@@ -56,61 +80,150 @@ def main():
5680
# Tools
5781
#
5882

83+
def parse_search_replace_blocks(patch_content):
84+
"""
85+
Parse multiple search-replace blocks from the patch content.
86+
Returns a list of tuples (search_text, replace_text).
87+
"""
88+
# Define the markers
89+
search_marker = "<<<<<<< SEARCH"
90+
separator = "======="
91+
replace_marker = ">>>>>>> REPLACE"
92+
93+
# Use regex to extract all blocks
94+
pattern = f"{search_marker}\\n(.*?)\\n{separator}\\n(.*?)\\n{replace_marker}"
95+
matches = re.findall(pattern, patch_content, re.DOTALL)
96+
97+
if not matches:
98+
# Try alternative parsing if regex fails
99+
blocks = []
100+
lines = patch_content.splitlines()
101+
i = 0
102+
while i < len(lines):
103+
if lines[i] == search_marker:
104+
search_start = i + 1
105+
separator_idx = -1
106+
replace_end = -1
107+
108+
# Find the separator
109+
for j in range(search_start, len(lines)):
110+
if lines[j] == separator:
111+
separator_idx = j
112+
break
113+
114+
if separator_idx == -1:
115+
raise ValueError("Invalid format: missing separator")
116+
117+
# Find the replace marker
118+
for j in range(separator_idx + 1, len(lines)):
119+
if lines[j] == replace_marker:
120+
replace_end = j
121+
break
122+
123+
if replace_end == -1:
124+
raise ValueError("Invalid format: missing replace marker")
125+
126+
search_text = "\n".join(lines[search_start:separator_idx])
127+
replace_text = "\n".join(lines[separator_idx + 1:replace_end])
128+
blocks.append((search_text, replace_text))
129+
130+
i = replace_end + 1
131+
else:
132+
i += 1
133+
134+
if blocks:
135+
return blocks
136+
else:
137+
raise ValueError("Invalid patch format. Expected block format with SEARCH/REPLACE markers.")
138+
139+
return matches
140+
141+
59142
@mcp.tool()
60143
def patch_file(
61144
file_path: str = Field(description="The path to the file to patch"),
62-
patch_content: str = Field(description="Unified diff/patch to apply to the file.")
145+
patch_content: str = Field(
146+
description="Content to search and replace in the file using block format with SEARCH/REPLACE markers. Multiple blocks are supported.")
63147
):
64148
"""
65-
Update the file by applying a unified diff/patch to it.
66-
The patch must be in unified diff format and will be applied to the current file content.
149+
Update the file by applying a patch/edit to it using block format.
150+
151+
Required format:
152+
```
153+
<<<<<<< SEARCH
154+
Text to find in the file
155+
=======
156+
Text to replace it with
157+
>>>>>>> REPLACE
158+
```
159+
160+
You can include multiple search-replace blocks in a single request:
161+
```
162+
<<<<<<< SEARCH
163+
First text to find
164+
=======
165+
First replacement
166+
>>>>>>> REPLACE
167+
<<<<<<< SEARCH
168+
Second text to find
169+
=======
170+
Second replacement
171+
>>>>>>> REPLACE
172+
```
173+
174+
This tool verifies that each search text appears exactly once in the file to ensure
175+
the correct section is modified. If a search text appears multiple times or isn't
176+
found, it will report an error.
67177
"""
68178
pp = Path(file_path).resolve()
69179
if not pp.exists() or not pp.is_file():
70180
raise FileNotFoundError(f"File {file_path} does not exist")
71181
if not any(str(pp).startswith(base) for base in allowed_directories):
72182
raise PermissionError(f"File {file_path} is not in allowed directories")
73183

74-
# Extract just the hunk part (starting with @@)
75-
lines = patch_content.splitlines()
76-
hunk_start = -1
77-
78-
for i, line in enumerate(lines):
79-
if line.startswith("@@"):
80-
hunk_start = i
81-
break
82-
83-
if hunk_start == -1:
84-
raise RuntimeError(
85-
"No @@ line markers found in the patch content.\n"
86-
"Make sure the patch follows the unified diff format with @@ line markers."
87-
)
88-
89-
# Use only the hunk part (remove any headers)
90-
hunk_content = "\n".join(lines[hunk_start:])
91-
92-
# Create a standardized patch with the correct filename
93-
filename = pp.name
94-
standardized_patch = f"--- {filename}\n+++ {filename}\n{hunk_content}"
95-
eprint(f"Created standardized patch for {filename}")
96-
97-
# Ensure patch_content is properly encoded
98-
encoded_content = standardized_patch.encode("utf-8")
99-
patchset = patch_ng.fromstring(encoded_content)
100-
if not patchset:
101-
raise RuntimeError(
102-
"Failed to parse patch string. You can use `write_file` tool to write the "
103-
"whole file content instead.\n"
104-
"Make sure the patch follows the unified diff format with @@ line markers."
105-
)
106-
107-
# Use the parent directory as root and the filename for patching
108-
parent_dir = str(pp.parent)
109-
success = patchset.apply(root=parent_dir)
110-
111-
if not success:
112-
raise RuntimeError(
113-
"Failed to apply patch to file. Use `write_file` tool to write the "
114-
"whole file content instead.\n"
115-
"Check that the patch lines match the target file content."
116-
)
184+
# Read the current file content
185+
with open(pp, 'r', encoding='utf-8') as f:
186+
original_content = f.read()
187+
188+
try:
189+
# Parse multiple search-replace blocks
190+
blocks = parse_search_replace_blocks(patch_content)
191+
if not blocks:
192+
raise ValueError("No valid search-replace blocks found in the patch content")
193+
194+
eprint(f"Found {len(blocks)} search-replace blocks")
195+
196+
# Apply each block sequentially
197+
current_content = original_content
198+
applied_blocks = 0
199+
200+
for i, (search_text, replace_text) in enumerate(blocks):
201+
eprint(f"Processing block {i+1}/{len(blocks)}")
202+
203+
# Check exact match count
204+
count = current_content.count(search_text)
205+
206+
if count == 1:
207+
# Exactly one match - perfect!
208+
eprint(f"Block {i+1}: Found exactly one exact match")
209+
current_content = current_content.replace(search_text, replace_text)
210+
applied_blocks += 1
211+
212+
elif count > 1:
213+
# Multiple matches - too ambiguous
214+
raise ValueError(f"Block {i+1}: The search text appears {count} times in the file. "
215+
"Please provide more context to identify the specific occurrence.")
216+
217+
else:
218+
# No match found
219+
raise ValueError(f"Block {i+1}: Could not find the search text in the file. "
220+
"Please ensure the search text exactly matches the content in the file.")
221+
222+
# Write the final content back to the file
223+
with open(pp, 'w', encoding='utf-8') as f:
224+
f.write(current_content)
225+
226+
return f"Successfully applied {applied_blocks} patch blocks to {file_path}"
227+
228+
except Exception as e:
229+
raise RuntimeError(f"Failed to apply patch: {str(e)}")

0 commit comments

Comments
 (0)