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