Skip to main content

lean_ctx_server/tools/registered/
ctx_edit.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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        // The mod draws ctx_patch results only; a direct ctx_edit carries no
50        // diff trailer.
51        handle_edit(args, ctx, false)
52    }
53}
54
55/// `ctx_edit`'s write path; `ctx_patch replace_unique` delegates here with
56/// `diff_artifact` set for a host that draws the diff. Writes check unit
57/// claims ([`crate::tools::claims_gate`]).
58pub(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        // #1592: the guard is a BLAKE3 digest; `expected_md5` was a misnomer and
80        // stays accepted so existing callers keep working.
81        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        // The diff and digests are opt-in: the default receipt is one line.
93        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            // Serialize edits to the SAME file via a cheap per-file lock. This
123            // lets the (slow) disk read/replace/write run WITHOUT holding the
124            // global cache write-lock, so concurrent agents editing different
125            // files never block each other (issue #320). Correctness for same-file
126            // edits is still guaranteed by the TOCTOU preimage guard + atomic
127            // rename inside run_io.
128            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            // Brief shared lock: read the recorded read-mode for auto-escalation.
148            // On contention we simply skip escalation rather than blocking I/O.
149            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            // Heavy disk I/O — no global cache lock held here.
159            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            // The file already holds the requested text: nothing written, and
163            // nothing failed either.
164            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            // Quality loop (#494): feed success/old_string-miss back into
173            // per-(ext × mode) stats and the one-shot read escalation.
174            crate::tools::ctx_edit::record_outcome(&edit_params, &last_mode, &output, &effect);
175
176            // Apply the deferred cache mutation under a brief exclusive lock.
177            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            // Keep recovery/cache effects, but never report a rejected edit as
192            // successful or mark its file modified. Dispatch turns this into a
193            // filtered MCP tool error through the full output pipeline.
194            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        // Facts anchored to this file are to verify once its code moved.
229        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}