lean_ctx/tools/registered/
ctx_multi_read.rs1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{
6 get_bool, get_str, get_str_array, McpTool, ToolContext, ToolOutput,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxMultiReadTool;
11
12impl McpTool for CtxMultiReadTool {
13 fn name(&self) -> &'static str {
14 "ctx_multi_read"
15 }
16
17 fn tool_def(&self) -> Tool {
18 tool_def(
19 "ctx_multi_read",
20 "Batch read files in one call. Same modes as ctx_read.",
21 json!({
22 "type": "object",
23 "properties": {
24 "paths": {
25 "type": "array",
26 "items": { "type": "string" },
27 "description": "Absolute file paths to read, in order"
28 },
29 "mode": {
30 "type": "string",
31 "description": "Compression mode (default: full). Same modes as ctx_read (auto, full, map, signatures, diff, aggressive, entropy, task, reference, lines:N-M)."
32 },
33 "fresh": {
34 "type": "boolean",
35 "description": "Bypass cache and force a full re-read for all paths. Use when running as a subagent that may not have the parent's context."
36 }
37 },
38 "required": ["paths"]
39 }),
40 )
41 }
42
43 fn handle(
44 &self,
45 args: &Map<String, Value>,
46 ctx: &ToolContext,
47 ) -> Result<ToolOutput, ErrorData> {
48 let raw_paths = get_str_array(args, "paths")
49 .ok_or_else(|| ErrorData::invalid_params("paths array is required", None))?;
50
51 tokio::task::block_in_place(|| {
52 let session_lock = ctx
53 .session
54 .as_ref()
55 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
56 let cache_lock = ctx
57 .cache
58 .as_ref()
59 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
60
61 let cap = crate::core::limits::max_read_bytes() as u64;
62 let mut paths = Vec::with_capacity(raw_paths.len());
63 {
64 let session_guard = tokio::task::block_in_place(|| {
65 let rt = tokio::runtime::Handle::current();
66 rt.block_on(tokio::time::timeout(
67 std::time::Duration::from_secs(5),
68 session_lock.read(),
69 ))
70 });
71 let Ok(session) = session_guard else {
72 return Err(ErrorData::internal_error(
73 "session read-lock timeout (5s) in ctx_multi_read",
74 None,
75 ));
76 };
77 for p in &raw_paths {
78 let resolved = super::resolve_path_sync(&session, p)
79 .map_err(|e| ErrorData::invalid_params(e, None))?;
80 if crate::core::binary_detect::is_binary_file(&resolved) {
81 continue;
82 }
83 if let Ok(meta) = std::fs::metadata(&resolved) {
84 if meta.len() > cap {
85 continue;
86 }
87 }
88 paths.push(resolved);
89 }
90 }
91 if paths.is_empty() {
92 return Err(ErrorData::invalid_params(
93 "all paths are binary or exceed the size limit",
94 None,
95 ));
96 }
97
98 let mode = get_str(args, "mode").unwrap_or_else(|| {
99 let p = crate::core::profiles::active_profile();
100 let dm = p.read.default_mode_effective();
101 if dm == "auto" {
102 "full".to_string()
103 } else {
104 dm.to_string()
105 }
106 });
107 let current_task = {
108 let guard = tokio::task::block_in_place(|| {
109 let rt = tokio::runtime::Handle::current();
110 rt.block_on(tokio::time::timeout(
111 std::time::Duration::from_secs(5),
112 session_lock.read(),
113 ))
114 });
115 if let Ok(session) = guard {
116 session.task.as_ref().map(|t| t.description.clone())
117 } else {
118 None
119 }
120 };
121
122 let fresh = get_bool(args, "fresh").unwrap_or(false);
123 let cache_guard = tokio::task::block_in_place(|| {
124 let rt = tokio::runtime::Handle::current();
125 rt.block_on(tokio::time::timeout(
126 std::time::Duration::from_secs(15),
127 cache_lock.write(),
128 ))
129 });
130 let Ok(mut cache) = cache_guard else {
131 return Err(ErrorData::internal_error(
132 "cache write-lock timeout (15s) in ctx_multi_read — another tool may be holding it. Retry in a moment.",
133 None,
134 ));
135 };
136 let output = crate::tools::ctx_multi_read::handle_with_task_fresh(
137 &mut cache,
138 &paths,
139 &mode,
140 fresh,
141 ctx.crp_mode,
142 current_task.as_deref(),
143 );
144 let mut total_original: usize = 0;
145 for path in &paths {
146 total_original =
147 total_original.saturating_add(cache.get(path).map_or(0, |e| e.original_tokens));
148 }
149 let tokens = crate::core::tokens::count_tokens(&output);
150 drop(cache);
151
152 Ok(ToolOutput {
153 text: output,
154 original_tokens: total_original,
155 saved_tokens: total_original.saturating_sub(tokens),
156 mode: Some(mode),
157 path: None,
158 changed: false,
159 })
160 })
161 }
162}