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(
59 args: &Map<String, Value>,
60 ctx: &ToolContext,
61 diff_artifact: bool,
62) -> Result<ToolOutput, ErrorData> {
63 crate::tools::claims_gate::scoped(ctx, || handle_edit_scoped(args, ctx, diff_artifact))
64}
65
66fn handle_edit_scoped(
67 args: &Map<String, Value>,
68 ctx: &ToolContext,
69 diff_artifact: bool,
70) -> Result<ToolOutput, ErrorData> {
71 {
72 let path = require_resolved_path(ctx, args, "path")?;
73
74 let old_string = get_str(args, "old_string").unwrap_or_default();
75 let new_string = get_str(args, "new_string")
76 .ok_or_else(|| ErrorData::invalid_params("new_string is required", None))?;
77 let replace_all = get_bool(args, "replace_all").unwrap_or(false);
78 let create = get_bool(args, "create").unwrap_or(false);
79 let expected_blake3 =
82 get_str(args, "expected_blake3").or_else(|| get_str(args, "expected_md5"));
83 let expected_size = get_int(args, "expected_size").and_then(|v| u64::try_from(v).ok());
84 let expected_mtime_ms =
85 get_int(args, "expected_mtime_ms").and_then(|v| u64::try_from(v).ok());
86 let backup = get_bool(args, "backup").unwrap_or(false);
87 let backup_path = get_str(args, "backup_path")
88 .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p))
89 .map(|p| lean_ctx::core::workspace_authority::confine(&p, &ctx.project_root, true))
90 .transpose()
91 .map_err(|error| ErrorData::invalid_params(error, None))?;
92 let evidence = get_bool(args, "evidence").unwrap_or(false);
94 let diff_max_lines = get_int(args, "diff_max_lines")
95 .and_then(|v| usize::try_from(v.max(0)).ok())
96 .unwrap_or(200);
97 let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
98
99 let edit_params = crate::tools::ctx_edit::EditParams {
100 path: path.clone(),
101 old_string,
102 new_string,
103 replace_all,
104 create,
105 expected_blake3,
106 expected_size,
107 expected_mtime_ms,
108 backup,
109 backup_path,
110 evidence,
111 diff_artifact,
112 diff_max_lines,
113 allow_lossy_utf8,
114 };
115
116 {
117 let cache_lock = ctx
118 .cache
119 .as_ref()
120 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
121
122 let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
129 let _file_guard = {
130 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
131 loop {
132 if let Ok(guard) = file_lock.try_lock() {
133 break guard;
134 }
135 if std::time::Instant::now() >= deadline {
136 return Err(ErrorData::internal_error(
137 format!(
138 "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
139 ),
140 None,
141 ));
142 }
143 std::thread::sleep(std::time::Duration::from_millis(20));
144 }
145 };
146
147 let last_mode =
150 match crate::server::bounded_lock::read(cache_lock, "ctx_edit cache read") {
151 Some(cache) => cache
152 .get(&path)
153 .map(|e| e.last_mode.clone())
154 .unwrap_or_default(),
155 None => String::new(),
156 };
157
158 let before = std::fs::read(&path).unwrap_or_default();
160 let (output, effect) = crate::tools::ctx_edit::run_io(&edit_params, &last_mode);
161 let written = matches!(effect, crate::tools::ctx_edit::CacheEffect::Invalidate);
162 let unchanged =
165 !written && output.starts_with(crate::tools::ctx_edit::NO_CHANGE_PREFIX);
166
167 if written {
168 let after = std::fs::read(&path).unwrap_or_default();
169 observe_mcp_edit(ctx, &path, "ctx_edit", &before, &after);
170 }
171
172 crate::tools::ctx_edit::record_outcome(&edit_params, &last_mode, &output, &effect);
175
176 if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
178 crate::tools::ctx_read::dedup_hook::on_write(&path);
179 match crate::server::bounded_lock::write(cache_lock, "ctx_edit cache write") {
180 Some(mut cache) => {
181 crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
182 }
183 None => {
184 tracing::warn!(
185 "ctx_edit: cache write-lock timeout applying post-edit effect for {path}"
186 );
187 }
188 }
189 }
190
191 if !written && !unchanged {
195 return Err(tool_execution_error(output));
196 }
197
198 if let (true, Some(session_lock)) = (written, ctx.session.as_ref()) {
199 if let Some(mut session) =
200 crate::server::bounded_lock::write(session_lock, "ctx_edit session write")
201 {
202 session.mark_modified(&path);
203 }
204 }
205
206 Ok(ToolOutput {
207 text: output,
208 original_tokens: 0,
209 saved_tokens: 0,
210 mode: None,
211 path: Some(path),
212 changed: false,
213 shell_outcome: None,
214 content_blocks: None,
215 })
216 }
217 }
218}
219
220pub(crate) fn observe_mcp_edit(
221 ctx: &ToolContext,
222 path: &str,
223 tool: &str,
224 before: &[u8],
225 after: &[u8],
226) {
227 if before != after && !ctx.project_root.is_empty() {
228 crate::tools::ctx_knowledge::freshness::mark_edited(&ctx.project_root, path);
230 }
231 let config = lean_ctx::core::config::Config::load();
232 if !config.provenance.enabled || !config.provenance.capture_mcp_edits || before == after {
233 return;
234 }
235
236 let root = ctx.project_root.as_str();
237 if root.is_empty() {
238 return;
239 }
240
241 let before_text = String::from_utf8_lossy(before);
242 let after_text = String::from_utf8_lossy(after);
243 let (lines_added, lines_removed) = TextDiff::from_lines(&before_text, &after_text)
244 .iter_all_changes()
245 .fold((0_u64, 0_u64), |(added, removed), change| {
246 match change.tag() {
247 ChangeTag::Insert => (added.saturating_add(1), removed),
248 ChangeTag::Delete => (added, removed.saturating_add(1)),
249 ChangeTag::Equal => (added, removed),
250 }
251 });
252 let tracked_path = Path::new(path)
253 .strip_prefix(root)
254 .ok()
255 .filter(|relative| !relative.as_os_str().is_empty())
256 .map(|relative| relative.to_string_lossy().into_owned())
257 .unwrap_or_else(|| path.to_owned());
258 let session_id = ctx
259 .session
260 .as_ref()
261 .and_then(|session| {
262 crate::server::bounded_lock::read(session, "provenance session read")
263 .map(|session| session.id.clone())
264 })
265 .unwrap_or_else(|| "mcp".to_owned());
266 let agent_id = ctx
267 .agent_id
268 .as_ref()
269 .and_then(|agent_id| {
270 crate::server::bounded_lock::read(agent_id, "provenance agent read")
271 .and_then(|agent_id| (*agent_id).clone())
272 })
273 .unwrap_or_else(|| "mcp".to_owned());
274
275 let _ = lean_ctx::core::provenance::ProvenanceTracker::new(root).and_then(|tracker| {
276 tracker.observe_edit(
277 tracked_path,
278 tool,
279 sha256_hex(before),
280 sha256_hex(after),
281 lines_added,
282 lines_removed,
283 session_id,
284 agent_id,
285 )
286 });
287}
288
289fn sha256_hex(data: &[u8]) -> String {
290 use sha2::{Digest, Sha256};
291 let hash = Sha256::digest(data);
292 let mut hex = String::with_capacity(64);
293 for b in &hash {
294 use std::fmt::Write;
295 let _ = write!(hex, "{b:02x}");
296 }
297 hex
298}