lean_ctx/tools/registered/
ctx_edit.rs1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{
6 get_bool, get_int, get_str, require_resolved_path, McpTool, ToolContext, ToolOutput,
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 "Edit a file via search-and-replace. Use when the IDE's Edit tool requires Read but Read is unavailable.",
21 json!({
22 "type": "object",
23 "properties": {
24 "path": { "type": "string", "description": "Absolute file path" },
25 "old_string": { "type": "string", "description": "Exact text to replace (unique unless replace_all)" },
26 "new_string": { "type": "string", "description": "Replacement text" },
27 "replace_all": { "type": "boolean", "default": false },
28 "create": { "type": "boolean", "description": "Create new file from new_string", "default": false }
29 },
30 "required": ["path", "new_string"]
31 }),
32 )
33 }
34
35 fn handle(
36 &self,
37 args: &Map<String, Value>,
38 ctx: &ToolContext,
39 ) -> Result<ToolOutput, ErrorData> {
40 let path = require_resolved_path(ctx, args, "path")?;
41
42 let old_string = get_str(args, "old_string").unwrap_or_default();
43 let new_string = get_str(args, "new_string")
44 .ok_or_else(|| ErrorData::invalid_params("new_string is required", None))?;
45 let replace_all = get_bool(args, "replace_all").unwrap_or(false);
46 let create = get_bool(args, "create").unwrap_or(false);
47 let expected_md5 = get_str(args, "expected_md5");
48 let expected_size = get_int(args, "expected_size").and_then(|v| u64::try_from(v).ok());
49 let expected_mtime_ms =
50 get_int(args, "expected_mtime_ms").and_then(|v| u64::try_from(v).ok());
51 let backup = get_bool(args, "backup").unwrap_or(false);
52 let backup_path = get_str(args, "backup_path")
53 .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
54 let evidence = get_bool(args, "evidence").unwrap_or(true);
55 let diff_max_lines = get_int(args, "diff_max_lines")
56 .and_then(|v| usize::try_from(v.max(0)).ok())
57 .unwrap_or(200);
58 let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
59
60 let edit_params = crate::tools::ctx_edit::EditParams {
61 path: path.clone(),
62 old_string,
63 new_string,
64 replace_all,
65 create,
66 expected_md5,
67 expected_size,
68 expected_mtime_ms,
69 backup,
70 backup_path,
71 evidence,
72 diff_max_lines,
73 allow_lossy_utf8,
74 };
75
76 tokio::task::block_in_place(|| {
77 let cache_lock = ctx
78 .cache
79 .as_ref()
80 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
81 let rt = tokio::runtime::Handle::current();
82
83 let file_lock = crate::core::path_locks::per_file_lock(&path);
90 let _file_guard = {
91 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
92 loop {
93 if let Ok(guard) = file_lock.try_lock() {
94 break guard;
95 }
96 if std::time::Instant::now() >= deadline {
97 return Err(ErrorData::internal_error(
98 format!("per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"),
99 None,
100 ));
101 }
102 std::thread::sleep(std::time::Duration::from_millis(20));
103 }
104 };
105
106 let last_mode = match rt.block_on(tokio::time::timeout(
109 std::time::Duration::from_secs(5),
110 cache_lock.read(),
111 )) {
112 Ok(cache) => cache
113 .get(&path)
114 .map(|e| e.last_mode.clone())
115 .unwrap_or_default(),
116 Err(_) => String::new(),
117 };
118
119 let (output, effect) = crate::tools::ctx_edit::run_io(&edit_params, &last_mode);
121
122 crate::tools::ctx_edit::record_outcome(&edit_params, &last_mode, &output, &effect);
125
126 if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
128 match rt.block_on(tokio::time::timeout(
129 std::time::Duration::from_secs(5),
130 cache_lock.write(),
131 )) {
132 Ok(mut cache) => {
133 crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
134 }
135 Err(_) => {
136 tracing::warn!(
137 "ctx_edit: cache write-lock timeout (5s) applying post-edit cache effect for {path}"
138 );
139 }
140 }
141 }
142
143 if let Some(session_lock) = ctx.session.as_ref() {
144 let guard = rt.block_on(tokio::time::timeout(
145 std::time::Duration::from_secs(5),
146 session_lock.write(),
147 ));
148 if let Ok(mut session) = guard {
149 session.mark_modified(&path);
150 }
151 }
152
153 Ok(ToolOutput {
154 text: output,
155 original_tokens: 0,
156 saved_tokens: 0,
157 mode: None,
158 path: Some(path),
159 changed: false,
160 shell_outcome: None,
161 })
162 })
163 }
164}