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::{get_bool, get_int, get_str, McpTool, ToolContext, ToolOutput};
6use crate::tool_defs::tool_def;
7
8pub struct CtxEditTool;
9
10impl McpTool for CtxEditTool {
11 fn name(&self) -> &'static str {
12 "ctx_edit"
13 }
14
15 fn tool_def(&self) -> Tool {
16 tool_def(
17 "ctx_edit",
18 "Edit a file via search-and-replace. Works without native Read/Edit tools. Use this when the IDE's Edit tool requires Read but Read is unavailable.",
19 json!({
20 "type": "object",
21 "properties": {
22 "path": { "type": "string", "description": "Absolute file path" },
23 "old_string": { "type": "string", "description": "Exact text to find and replace (must be unique unless replace_all=true)" },
24 "new_string": { "type": "string", "description": "Replacement text" },
25 "replace_all": { "type": "boolean", "description": "Replace all occurrences (default: false)", "default": false },
26 "create": { "type": "boolean", "description": "Create a new file with new_string as content (ignores old_string)", "default": false }
27 },
28 "required": ["path", "new_string"]
29 }),
30 )
31 }
32
33 fn handle(
34 &self,
35 args: &Map<String, Value>,
36 ctx: &ToolContext,
37 ) -> Result<ToolOutput, ErrorData> {
38 let path = ctx
39 .resolved_path("path")
40 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?
41 .to_string();
42
43 let old_string = get_str(args, "old_string").unwrap_or_default();
44 let new_string = get_str(args, "new_string")
45 .ok_or_else(|| ErrorData::invalid_params("new_string is required", None))?;
46 let replace_all = get_bool(args, "replace_all").unwrap_or(false);
47 let create = get_bool(args, "create").unwrap_or(false);
48 let expected_md5 = get_str(args, "expected_md5");
49 let expected_size = get_int(args, "expected_size").and_then(|v| u64::try_from(v).ok());
50 let expected_mtime_ms =
51 get_int(args, "expected_mtime_ms").and_then(|v| u64::try_from(v).ok());
52 let backup = get_bool(args, "backup").unwrap_or(false);
53 let backup_path = get_str(args, "backup_path")
54 .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
55 let evidence = get_bool(args, "evidence").unwrap_or(true);
56 let diff_max_lines = get_int(args, "diff_max_lines")
57 .and_then(|v| usize::try_from(v.max(0)).ok())
58 .unwrap_or(200);
59 let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
60
61 tokio::task::block_in_place(|| {
62 let cache_lock = ctx
63 .cache
64 .as_ref()
65 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
66 let mut cache = cache_lock.blocking_write();
67 let output = crate::tools::ctx_edit::handle(
68 &mut cache,
69 &crate::tools::ctx_edit::EditParams {
70 path: path.clone(),
71 old_string,
72 new_string,
73 replace_all,
74 create,
75 expected_md5,
76 expected_size,
77 expected_mtime_ms,
78 backup,
79 backup_path,
80 evidence,
81 diff_max_lines,
82 allow_lossy_utf8,
83 },
84 );
85 drop(cache);
86
87 if let Some(session_lock) = ctx.session.as_ref() {
88 let mut session = session_lock.blocking_write();
89 session.mark_modified(&path);
90 }
91
92 Ok(ToolOutput {
93 text: output,
94 original_tokens: 0,
95 saved_tokens: 0,
96 mode: None,
97 path: Some(path),
98 })
99 })
100 }
101}