1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6 McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, require_resolved_path,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxPatchTool;
11
12impl McpTool for CtxPatchTool {
13 fn name(&self) -> &'static str {
14 "ctx_patch"
15 }
16
17 fn tool_def(&self) -> Tool {
23 tool_def(
24 "ctx_patch",
25 "Hash-anchored edit — patch by (line,hash) anchor from ctx_read(anchored)/ctx_search(anchored=true).\n\
26 Ops: set_line(line,hash,new_text) | replace_lines(start_line/hash,end_line/hash,new_text) |\n\
27 insert_after(line,hash,new_text) | delete(line,hash or start/end range) |\n\
28 replace_symbol(name,new_body) | create(new_text) | replace_all(find,replace,dry_run).\n\
29 Batch: ops:[{…}] (not replace_symbol/replace_all). Stale → CONFLICT.",
30 json!({
31 "type": "object",
32 "properties": {
33 "path": { "type": "string" },
34 "op": { "type": "string", "enum": ["set_line", "replace_lines", "insert_after", "delete", "replace_symbol", "create", "replace_all"] },
35 "line": { "type": "integer" },
36 "hash": { "type": "string" },
37 "start_line": { "type": "integer" },
38 "start_hash": { "type": "string" },
39 "end_line": { "type": "integer" },
40 "end_hash": { "type": "string" },
41 "new_text": { "type": "string" },
42 "name": { "type": "string" },
43 "new_body": { "type": "string" },
44 "find": { "type": "string", "description": "Literal text to find (replace_all)" },
45 "replace": { "type": "string", "description": "Replacement text (replace_all)" },
46 "dry_run": { "type": "boolean", "description": "Preview only, do not write (replace_all)" },
47 "ops": { "type": "array", "items": { "type": "object" } }
48 },
49 "required": ["path"]
50 }),
51 )
52 }
53
54 fn handle(
55 &self,
56 args: &Map<String, Value>,
57 ctx: &ToolContext,
58 ) -> Result<ToolOutput, ErrorData> {
59 if crate::tools::ctx_patch::is_replace_symbol(args) {
62 return delegate_replace_symbol(args, ctx);
63 }
64
65 if get_str(args, "op").as_deref() == Some("replace_all") {
67 return handle_replace_all(args, ctx);
68 }
69
70 let path = require_resolved_path(ctx, args, "path")?;
71
72 let ops = crate::tools::ctx_patch::parse_ops(args)
73 .map_err(|e| ErrorData::invalid_params(e, None))?;
74
75 let expected_md5 = get_str(args, "expected_md5");
76 let backup = get_bool(args, "backup").unwrap_or(false);
77 let backup_path = get_str(args, "backup_path")
78 .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
79 let evidence = get_bool(args, "evidence").unwrap_or(true);
80 let diff_max_lines = get_int(args, "diff_max_lines")
81 .and_then(|v| usize::try_from(v.max(0)).ok())
82 .unwrap_or(200);
83 let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
84 let validate_syntax = get_bool(args, "validate_syntax").unwrap_or(true);
85
86 let patch_params = crate::tools::ctx_patch::PatchParams {
87 path: path.clone(),
88 ops,
89 expected_md5,
90 backup,
91 backup_path,
92 evidence,
93 diff_max_lines,
94 allow_lossy_utf8,
95 validate_syntax,
96 };
97
98 tokio::task::block_in_place(|| {
99 let cache_lock = ctx
100 .cache
101 .as_ref()
102 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
103 let rt = tokio::runtime::Handle::current();
104
105 let file_lock = crate::core::path_locks::per_file_lock(&path);
110 let _file_guard = {
111 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
112 loop {
113 if let Ok(guard) = file_lock.try_lock() {
114 break guard;
115 }
116 if std::time::Instant::now() >= deadline {
117 return Err(ErrorData::internal_error(
118 format!(
119 "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
120 ),
121 None,
122 ));
123 }
124 std::thread::sleep(std::time::Duration::from_millis(20));
125 }
126 };
127
128 let last_mode = match rt.block_on(tokio::time::timeout(
129 std::time::Duration::from_secs(5),
130 cache_lock.read(),
131 )) {
132 Ok(cache) => cache
133 .get(&path)
134 .map(|e| e.last_mode.clone())
135 .unwrap_or_default(),
136 Err(_) => String::new(),
137 };
138
139 let (output, effect) = crate::tools::ctx_patch::run_io(&patch_params, &last_mode);
141
142 crate::tools::ctx_patch::record_outcome(&patch_params, &last_mode, &output, &effect);
143
144 if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
145 match rt.block_on(tokio::time::timeout(
146 std::time::Duration::from_secs(5),
147 cache_lock.write(),
148 )) {
149 Ok(mut cache) => {
150 crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
151 }
152 Err(_) => {
153 tracing::warn!(
154 "ctx_patch: cache write-lock timeout (5s) applying post-edit cache effect for {path}"
155 );
156 }
157 }
158 }
159
160 if let Some(session_lock) = ctx.session.as_ref() {
161 let guard = rt.block_on(tokio::time::timeout(
162 std::time::Duration::from_secs(5),
163 session_lock.write(),
164 ));
165 if let Ok(mut session) = guard {
166 session.mark_modified(&path);
167 }
168 }
169
170 Ok(ToolOutput {
171 text: output,
172 original_tokens: 0,
173 saved_tokens: 0,
174 mode: None,
175 path: Some(path),
176 changed: false,
177 shell_outcome: None,
178 content_blocks: None,
179 })
180 })
181 }
182}
183
184fn delegate_replace_symbol(
189 args: &Map<String, Value>,
190 ctx: &ToolContext,
191) -> Result<ToolOutput, ErrorData> {
192 let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
193 .map_err(|e| ErrorData::invalid_params(e, None))?;
194
195 let has_path = args.get("path").and_then(Value::as_str).is_some();
199 let abs_path = if has_path {
200 require_resolved_path(ctx, args, "path")?
201 } else {
202 String::new()
203 };
204
205 let args_value = Value::Object(refactor_args);
206 let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
207 let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
208
209 Ok(ToolOutput {
210 text: result,
211 original_tokens: 0,
212 saved_tokens: 0,
213 mode: Some("replace_symbol".to_string()),
214 path: get_str(args, "path"),
215 changed,
216 shell_outcome: None,
217 content_blocks: None,
218 })
219}
220
221fn resolve_find_replace(args: &Map<String, Value>) -> Result<(String, String), String> {
228 let find = get_str(args, "find")
229 .filter(|s| !s.is_empty())
230 .ok_or("replace_all requires non-empty 'find'")?;
231
232 for foreign in ["new_text", "new_string", "old_string", "new_body"] {
233 if args.contains_key(foreign) {
234 return Err(format!(
235 "replace_all names its replacement 'replace', not '{foreign}' — rename it \
236 (an unrecognized replacement key would silently delete every match)"
237 ));
238 }
239 }
240
241 let replace = args
242 .get("replace")
243 .and_then(Value::as_str)
244 .map(String::from)
245 .ok_or(
246 "replace_all requires 'replace' (the replacement text); pass replace=\"\" \
247 explicitly to delete every match",
248 )?;
249
250 Ok((find, replace))
251}
252
253fn handle_replace_all(
255 args: &Map<String, Value>,
256 ctx: &ToolContext,
257) -> Result<ToolOutput, ErrorData> {
258 let path = require_resolved_path(ctx, args, "path")?;
259 let (find, replace) =
260 resolve_find_replace(args).map_err(|e| ErrorData::invalid_params(e, None))?;
261 let dry_run = get_bool(args, "dry_run").unwrap_or(false);
262
263 let content = std::fs::read_to_string(&path)
264 .map_err(|e| ErrorData::internal_error(format!("cannot read {path}: {e}"), None))?;
265
266 let count = content.matches(find.as_str()).count();
267 if count == 0 {
268 return Ok(ToolOutput::simple(format!(
269 "No matches for {find:?} in {path}"
270 )));
271 }
272
273 if dry_run {
274 return Ok(ToolOutput::simple(format!(
275 "DRY RUN: {count} occurrence(s) of {find:?} would be replaced with {replace:?} in {path}"
276 )));
277 }
278
279 let file_lock = crate::core::path_locks::per_file_lock(&path);
280 let _guard = file_lock
281 .lock()
282 .map_err(|_| ErrorData::internal_error(format!("lock contention for {path}"), None))?;
283
284 let new_content = content.replace(find.as_str(), &replace);
285 crate::config_io::write_atomic(std::path::Path::new(&path), &new_content)
286 .map_err(|e| ErrorData::internal_error(format!("write failed: {e}"), None))?;
287
288 if let Some(cache) = ctx.cache.as_ref() {
289 let rt = tokio::runtime::Handle::current();
290 if let Ok(mut c) = rt.block_on(tokio::time::timeout(
291 std::time::Duration::from_secs(2),
292 cache.write(),
293 )) {
294 c.invalidate(&path);
295 }
296 }
297
298 Ok(ToolOutput::simple(format!(
299 "Replaced {count} occurrence(s) of {find:?} with {replace:?} in {path}"
300 )))
301}
302
303#[cfg(test)]
304mod replace_all_tests {
305 use super::*;
306 use serde_json::json;
307
308 fn obj(v: Value) -> Map<String, Value> {
309 match v {
310 Value::Object(m) => m,
311 _ => panic!("expected object"),
312 }
313 }
314
315 #[test]
316 fn resolves_find_and_replace() {
317 let (f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": "b"}))).unwrap();
318 assert_eq!((f.as_str(), r.as_str()), ("a", "b"));
319 }
320
321 #[test]
322 fn explicit_empty_replace_is_a_deletion() {
323 let (_f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": ""}))).unwrap();
324 assert_eq!(r, "");
325 }
326
327 #[test]
328 fn missing_replace_is_rejected_not_silent_delete() {
329 let err = resolve_find_replace(&obj(json!({"find": "a"}))).unwrap_err();
330 assert!(err.contains("requires 'replace'"), "got: {err}");
331 }
332
333 #[test]
334 fn foreign_replacement_key_is_rejected() {
335 for key in ["new_string", "new_text", "old_string", "new_body"] {
336 let err = resolve_find_replace(&obj(json!({"find": "a", key: "b"}))).unwrap_err();
337 assert!(
338 err.contains(key),
339 "must name the offending key {key}: {err}"
340 );
341 }
342 }
343
344 #[test]
345 fn empty_find_is_rejected() {
346 let err = resolve_find_replace(&obj(json!({"find": "", "replace": "b"}))).unwrap_err();
347 assert!(err.contains("find"), "got: {err}");
348 }
349}