2
2
import sys
3
3
import argparse
4
4
from pathlib import Path
5
+ import re
5
6
6
7
from fastmcp import FastMCP
7
8
from pydantic .fields import Field
8
- import patch_ng
9
9
10
10
11
11
mcp = FastMCP (
12
12
name = "Patch File MCP" ,
13
13
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.
18
42
"""
19
43
)
20
44
@@ -28,7 +52,7 @@ def eprint(*args, **kwargs):
28
52
def main ():
29
53
# Process command line arguments
30
54
global allowed_directories
31
- parser = argparse .ArgumentParser (description = "Project Memory MCP server" )
55
+ parser = argparse .ArgumentParser (description = "Patch File MCP server" )
32
56
parser .add_argument (
33
57
'--allowed-dir' ,
34
58
action = 'append' ,
@@ -56,61 +80,150 @@ def main():
56
80
# Tools
57
81
#
58
82
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
+
59
142
@mcp .tool ()
60
143
def patch_file (
61
144
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." )
63
147
):
64
148
"""
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.
67
177
"""
68
178
pp = Path (file_path ).resolve ()
69
179
if not pp .exists () or not pp .is_file ():
70
180
raise FileNotFoundError (f"File { file_path } does not exist" )
71
181
if not any (str (pp ).startswith (base ) for base in allowed_directories ):
72
182
raise PermissionError (f"File { file_path } is not in allowed directories" )
73
183
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