lean_ctx_server/tools/registered/
ctx_edit.rs1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5use similar::{ChangeTag, TextDiff};
6use std::path::Path;
7
8use crate::server::tool_trait::{
9 McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, require_resolved_path,
10 tool_execution_error,
11};
12use lean_ctx::tool_defs::tool_def;
13
14pub struct CtxEditTool;
15
16impl McpTool for CtxEditTool {
17 fn name(&self) -> &'static str {
18 "ctx_edit"
19 }
20
21 fn tool_def(&self) -> Tool {
22 tool_def(
23 "ctx_edit",
24 "Search-and-replace edit with race-condition guards — for simple text replacement in a single file.\n\
25 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\
26 old_string must be unique unless replace_all=true. create=true writes new files.\n\
27 backup creates .bak. MD5/size/mtime pre-guards prevent race conditions.\n\
28 ANTIPATTERN: Do NOT loop on failures — switch to ctx_patch (anchored), or verify file content and adjust old_string.\n\
29 To replace a whole symbol body use ctx_patch(op=\"replace_symbol\"); find its references with ctx_callgraph.",
30 json!({
31 "type": "object",
32 "properties": {
33 "path": { "type": "string", "description": "File path to edit" },
34 "old_string": { "type": "string", "description": "Text to replace (unique unless replace_all=true)" },
35 "new_string": { "type": "string", "description": "Replacement text" },
36 "replace_all": { "type": "boolean", "description": "Replace all occurrences (default false)", "default": false },
37 "create": { "type": "boolean", "description": "Create file", "default": false }
38 },
39 "required": ["path", "new_string"]
40 }),
41 )
42 }
43
44 fn handle(
45 &self,
46 args: &Map<String, Value>,
47 ctx: &ToolContext,
48 ) -> Result<ToolOutput, ErrorData> {
49 handle_edit(args, ctx, false)
52 }
53}
54
55pub(crate) fn handle_edit(
58 args: &Map<String, Value>,
59 ctx: &ToolContext,
60 diff_artifact: bool,
61) -> Result<ToolOutput, ErrorData> {
62 {
63 let path = require_resolved_path(ctx, args, "path")?;
64
65 let old_string = get_str(args, "old_string").unwrap_or_default();
66 let new_string = get_str(args, "new_string")
67 .ok_or_else(|| ErrorData::invalid_params("new_string is required", None))?;
68 let replace_all = get_bool(args, "replace_all").unwrap_or(false);
69 let create = get_bool(args, "create").unwrap_or(false);
70 let expected_blake3 =
73 get_str(args, "expected_blake3").or_else(|| get_str(args, "expected_md5"));
74 let expected_size = get_int(args, "expected_size").and_then(|v| u64::try_from(v).ok());
75 let expected_mtime_ms =
76 get_int(args, "expected_mtime_ms").and_then(|v| u64::try_from(v).ok());
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 .map(|p| lean_ctx::core::workspace_authority::confine(&p, &ctx.project_root, true))
81 .transpose()
82 .map_err(|error| ErrorData::invalid_params(error, None))?;
83 let evidence = get_bool(args, "evidence").unwrap_or(false);
85 let diff_max_lines = get_int(args, "diff_max_lines")
86 .and_then(|v| usize::try_from(v.max(0)).ok())
87 .unwrap_or(200);
88 let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
89
90 let edit_params = crate::tools::ctx_edit::EditParams {
91 path: path.clone(),
92 old_string,
93 new_string,
94 replace_all,
95 create,
96 expected_blake3,
97 expected_size,
98 expected_mtime_ms,
99 backup,
100 backup_path,
101 evidence,
102 diff_artifact,
103 diff_max_lines,
104 allow_lossy_utf8,
105 };
106
107 {
108 let cache_lock = ctx
109 .cache
110 .as_ref()
111 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
112
113 let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
120 let _file_guard = {
121 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
122 loop {
123 if let Ok(guard) = file_lock.try_lock() {
124 break guard;
125 }
126 if std::time::Instant::now() >= deadline {
127 return Err(ErrorData::internal_error(
128 format!(
129 "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
130 ),
131 None,
132 ));
133 }
134 std::thread::sleep(std::time::Duration::from_millis(20));
135 }
136 };
137
138 let last_mode =
141 match crate::server::bounded_lock::read(cache_lock, "ctx_edit cache read") {
142 Some(cache) => cache
143 .get(&path)
144 .map(|e| e.last_mode.clone())
145 .unwrap_or_default(),
146 None => String::new(),
147 };
148
149 let before = std::fs::read(&path).unwrap_or_default();
151 let (output, effect) = crate::tools::ctx_edit::run_io(&edit_params, &last_mode);
152 let written = matches!(effect, crate::tools::ctx_edit::CacheEffect::Invalidate);
153 let unchanged =
156 !written && output.starts_with(crate::tools::ctx_edit::NO_CHANGE_PREFIX);
157
158 if written {
159 let after = std::fs::read(&path).unwrap_or_default();
160 observe_mcp_edit(ctx, &path, "ctx_edit", &before, &after);
161 }
162
163 crate::tools::ctx_edit::record_outcome(&edit_params, &last_mode, &output, &effect);
166
167 if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
169 crate::tools::ctx_read::dedup_hook::on_write(&path);
170 match crate::server::bounded_lock::write(cache_lock, "ctx_edit cache write") {
171 Some(mut cache) => {
172 crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
173 }
174 None => {
175 tracing::warn!(
176 "ctx_edit: cache write-lock timeout applying post-edit effect for {path}"
177 );
178 }
179 }
180 }
181
182 if !written && !unchanged {
186 return Err(tool_execution_error(output));
187 }
188
189 if let (true, Some(session_lock)) = (written, ctx.session.as_ref()) {
190 if let Some(mut session) =
191 crate::server::bounded_lock::write(session_lock, "ctx_edit session write")
192 {
193 session.mark_modified(&path);
194 }
195 }
196
197 Ok(ToolOutput {
198 text: output,
199 original_tokens: 0,
200 saved_tokens: 0,
201 mode: None,
202 path: Some(path),
203 changed: false,
204 shell_outcome: None,
205 content_blocks: None,
206 })
207 }
208 }
209}
210
211pub(crate) fn observe_mcp_edit(
212 ctx: &ToolContext,
213 path: &str,
214 tool: &str,
215 before: &[u8],
216 after: &[u8],
217) {
218 let config = lean_ctx::core::config::Config::load();
219 if !config.provenance.enabled || !config.provenance.capture_mcp_edits || before == after {
220 return;
221 }
222
223 let root = ctx.project_root.as_str();
224 if root.is_empty() {
225 return;
226 }
227
228 let before_text = String::from_utf8_lossy(before);
229 let after_text = String::from_utf8_lossy(after);
230 let (lines_added, lines_removed) = TextDiff::from_lines(&before_text, &after_text)
231 .iter_all_changes()
232 .fold((0_u64, 0_u64), |(added, removed), change| {
233 match change.tag() {
234 ChangeTag::Insert => (added.saturating_add(1), removed),
235 ChangeTag::Delete => (added, removed.saturating_add(1)),
236 ChangeTag::Equal => (added, removed),
237 }
238 });
239 let tracked_path = Path::new(path)
240 .strip_prefix(root)
241 .ok()
242 .filter(|relative| !relative.as_os_str().is_empty())
243 .map(|relative| relative.to_string_lossy().into_owned())
244 .unwrap_or_else(|| path.to_owned());
245 let session_id = ctx
246 .session
247 .as_ref()
248 .and_then(|session| {
249 crate::server::bounded_lock::read(session, "provenance session read")
250 .map(|session| session.id.clone())
251 })
252 .unwrap_or_else(|| "mcp".to_owned());
253 let agent_id = ctx
254 .agent_id
255 .as_ref()
256 .and_then(|agent_id| {
257 crate::server::bounded_lock::read(agent_id, "provenance agent read")
258 .and_then(|agent_id| (*agent_id).clone())
259 })
260 .unwrap_or_else(|| "mcp".to_owned());
261
262 let _ = lean_ctx::core::provenance::ProvenanceTracker::new(root).and_then(|tracker| {
263 tracker.observe_edit(
264 tracked_path,
265 tool,
266 sha256_hex(before),
267 sha256_hex(after),
268 lines_added,
269 lines_removed,
270 session_id,
271 agent_id,
272 )
273 });
274}
275
276fn sha256_hex(data: &[u8]) -> String {
277 use sha2::{Digest, Sha256};
278 let hash = Sha256::digest(data);
279 let mut hex = String::with_capacity(64);
280 for b in &hash {
281 use std::fmt::Write;
282 let _ = write!(hex, "{b:02x}");
283 }
284 hex
285}