lean_ctx/tools/registered/
ctx_edit.rs1use 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 CtxEditTool;
11
12impl McpTool for CtxEditTool {
13 fn name(&self) -> &'static str {
14 "ctx_edit"
15 }
16
17 fn tool_def(&self) -> Tool {
18 tool_def(
19 "ctx_edit",
20 "Search-and-replace edit with race-condition guards — for simple text replacement in a single file.\n\
21 For editing code you've read, prefer ctx_patch (hash-anchored): it never makes you reproduce old text byte-for-byte. Read with ctx_read(mode=\"anchored\") first.\n\
22 old_string must be unique unless replace_all=true. create=true writes new files.\n\
23 backup creates .bak. MD5/size/mtime pre-guards prevent race conditions.\n\
24 ANTIPATTERN: Do NOT loop on failures — switch to ctx_patch (anchored), or verify file content and adjust old_string.\n\
25 For LSP-aware refactoring (rename, move, inline), use ctx_refactor.",
26 json!({
27 "type": "object",
28 "properties": {
29 "path": { "type": "string", "description": "File path to edit" },
30 "old_string": { "type": "string", "description": "Text to replace (unique unless replace_all=true)" },
31 "new_string": { "type": "string", "description": "Replacement text" },
32 "replace_all": { "type": "boolean", "description": "Replace all occurrences (default false)", "default": false },
33 "create": { "type": "boolean", "description": "Create file", "default": false }
34 },
35 "required": ["path", "new_string"]
36 }),
37 )
38 }
39
40 fn handle(
41 &self,
42 args: &Map<String, Value>,
43 ctx: &ToolContext,
44 ) -> Result<ToolOutput, ErrorData> {
45 let path = require_resolved_path(ctx, args, "path")?;
46
47 let old_string = get_str(args, "old_string").unwrap_or_default();
48 let new_string = get_str(args, "new_string")
49 .ok_or_else(|| ErrorData::invalid_params("new_string is required", None))?;
50 let replace_all = get_bool(args, "replace_all").unwrap_or(false);
51 let create = get_bool(args, "create").unwrap_or(false);
52 let expected_md5 = get_str(args, "expected_md5");
53 let expected_size = get_int(args, "expected_size").and_then(|v| u64::try_from(v).ok());
54 let expected_mtime_ms =
55 get_int(args, "expected_mtime_ms").and_then(|v| u64::try_from(v).ok());
56 let backup = get_bool(args, "backup").unwrap_or(false);
57 let backup_path = get_str(args, "backup_path")
58 .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
59 let evidence = get_bool(args, "evidence").unwrap_or(true);
60 let diff_max_lines = get_int(args, "diff_max_lines")
61 .and_then(|v| usize::try_from(v.max(0)).ok())
62 .unwrap_or(200);
63 let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
64
65 let edit_params = crate::tools::ctx_edit::EditParams {
66 path: path.clone(),
67 old_string,
68 new_string,
69 replace_all,
70 create,
71 expected_md5,
72 expected_size,
73 expected_mtime_ms,
74 backup,
75 backup_path,
76 evidence,
77 diff_max_lines,
78 allow_lossy_utf8,
79 };
80
81 tokio::task::block_in_place(|| {
82 let cache_lock = ctx
83 .cache
84 .as_ref()
85 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
86 let rt = tokio::runtime::Handle::current();
87
88 let file_lock = crate::core::path_locks::per_file_lock(&path);
95 let _file_guard = {
96 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
97 loop {
98 if let Ok(guard) = file_lock.try_lock() {
99 break guard;
100 }
101 if std::time::Instant::now() >= deadline {
102 return Err(ErrorData::internal_error(
103 format!(
104 "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
105 ),
106 None,
107 ));
108 }
109 std::thread::sleep(std::time::Duration::from_millis(20));
110 }
111 };
112
113 let last_mode = match rt.block_on(tokio::time::timeout(
116 std::time::Duration::from_secs(5),
117 cache_lock.read(),
118 )) {
119 Ok(cache) => cache
120 .get(&path)
121 .map(|e| e.last_mode.clone())
122 .unwrap_or_default(),
123 Err(_) => String::new(),
124 };
125
126 let (output, effect) = crate::tools::ctx_edit::run_io(&edit_params, &last_mode);
128
129 crate::tools::ctx_edit::record_outcome(&edit_params, &last_mode, &output, &effect);
132
133 if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
135 match rt.block_on(tokio::time::timeout(
136 std::time::Duration::from_secs(5),
137 cache_lock.write(),
138 )) {
139 Ok(mut cache) => {
140 crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
141 }
142 Err(_) => {
143 tracing::warn!(
144 "ctx_edit: cache write-lock timeout (5s) applying post-edit cache effect for {path}"
145 );
146 }
147 }
148 }
149
150 if let Some(session_lock) = ctx.session.as_ref() {
151 let guard = rt.block_on(tokio::time::timeout(
152 std::time::Duration::from_secs(5),
153 session_lock.write(),
154 ));
155 if let Ok(mut session) = guard {
156 session.mark_modified(&path);
157 }
158 }
159
160 Ok(ToolOutput {
161 text: output,
162 original_tokens: 0,
163 saved_tokens: 0,
164 mode: None,
165 path: Some(path),
166 changed: false,
167 shell_outcome: None,
168 content_blocks: None,
169 })
170 })
171 }
172}