22import sys
33import argparse
44from pathlib import Path
5+ import re
56
67from fastmcp import FastMCP
78from pydantic .fields import Field
8- import patch_ng
99
1010
1111mcp = 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):
2852def 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 ()
60143def 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