Skip to main content

lean_ctx/tools/registered/
ctx_multi_read.rs

1// noqa: SIZE_OK — single-responsibility tool handler, 255 pure LOC (5 over).
2// Inline tests (~80 lines) are conventional in Rust. Self-contained MCP tool wrapper.
3use rmcp::ErrorData;
4use rmcp::model::Tool;
5use serde_json::{Map, Value, json};
6
7use crate::server::tool_trait::{
8    McpTool, ToolContext, ToolOutput, get_bool, get_str, get_str_array,
9};
10use crate::tool_defs::tool_def;
11
12pub struct CtxMultiReadTool;
13
14impl McpTool for CtxMultiReadTool {
15    fn name(&self) -> &'static str {
16        "ctx_multi_read"
17    }
18
19    fn tool_def(&self) -> Tool {
20        tool_def(
21            "ctx_multi_read",
22            "DEPRECATED → use ctx_read with paths=['a.rs','b.rs']. Folded into ctx_read\n\
23             (#509); hidden from tools/list, still callable for one release.",
24            json!({
25                "type": "object",
26                "properties": {
27                    "paths": {
28                        "type": "array",
29                        "items": { "type": "string" },
30                        "description": "Paths to batch-read, in order"
31                    },
32                    "mode": {
33                        "type": "string",
34                        "default": "auto",
35                        "description": "auto|full|raw|signatures|map (same as ctx_read)"
36                    },
37                    "fresh": {
38                        "type": "boolean",
39                        "description": "Bypass cache, full re-read"
40                    }
41                },
42                "required": ["paths"]
43            }),
44        )
45    }
46
47    fn handle(
48        &self,
49        args: &Map<String, Value>,
50        ctx: &ToolContext,
51    ) -> Result<ToolOutput, ErrorData> {
52        batch_read(args, ctx)
53    }
54}
55
56/// Batch-read multiple files in one call. The single implementation shared by
57/// the (deprecated) `ctx_multi_read` tool and by `ctx_read` when it is called
58/// with a `paths` array (#509) — no duplicated batch logic across the two.
59///
60/// Panic guard (mirrors ctx_read): a panic in tree-sitter / compression must
61/// never unwind through the dispatch `block_in_place` and kill the MCP server.
62pub(crate) fn batch_read(
63    args: &Map<String, Value>,
64    ctx: &ToolContext,
65) -> Result<ToolOutput, ErrorData> {
66    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handle_inner(args, ctx))) {
67        Ok(result) => result,
68        Err(_) => Err(ErrorData::internal_error(
69            "ctx_multi_read panicked while processing the batch. This is a bug — please report it.",
70            None,
71        )),
72    }
73}
74
75fn handle_inner(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
76    let raw_paths = get_str_array(args, "paths")
77        .ok_or_else(|| ErrorData::invalid_params("paths array is required", None))?;
78
79    let session_lock = ctx
80        .session
81        .as_ref()
82        .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
83    let cache_lock = ctx
84        .cache
85        .as_ref()
86        .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
87
88    let cap = crate::core::limits::max_read_bytes() as u64;
89
90    // Resolve + filter paths and capture the active task under one short read lock.
91    // `bounded_lock` uses `Handle::block_on` directly — NOT a nested
92    // `block_in_place` — because the dispatch layer already wraps this handler in
93    // `block_in_place`. The previous nested `block_in_place` calls could exhaust the
94    // 32-thread blocking pool under concurrent reads and freeze the server (#271).
95    let (paths, current_task) = {
96        let Some(session) =
97            crate::server::bounded_lock::read(session_lock, "ctx_multi_read:session")
98        else {
99            return Err(ErrorData::internal_error(
100                "session read-lock timeout in ctx_multi_read — another tool may be holding it. Retry in a moment.",
101                None,
102            ));
103        };
104        let mut paths = Vec::with_capacity(raw_paths.len());
105        for p in &raw_paths {
106            let resolved = super::resolve_path_sync(&session, p)
107                .map_err(|e| ErrorData::invalid_params(e, None))?;
108            if crate::core::binary_detect::is_binary_file(&resolved) {
109                continue;
110            }
111            if let Ok(meta) = std::fs::metadata(&resolved)
112                && meta.len() > cap
113            {
114                continue;
115            }
116            paths.push(resolved);
117        }
118        let current_task = session.task.as_ref().map(|t| t.description.clone());
119        (paths, current_task)
120    };
121
122    if paths.is_empty() {
123        return Err(ErrorData::invalid_params(
124            "all paths are binary or exceed the size limit",
125            None,
126        ));
127    }
128
129    // Default to the profile's read mode (auto) and let ctx_read resolve the
130    // optimal mode per file. Previously this forced auto→full, which is exactly
131    // the "everything comes back as full" complaint (#421): batch reads must
132    // honour auto like single ctx_read does.
133    let mode = get_str(args, "mode").unwrap_or_else(|| {
134        crate::core::profiles::active_profile()
135            .read
136            .default_mode_effective()
137            .to_string()
138    });
139    let fresh = get_bool(args, "fresh").unwrap_or(false);
140
141    // Batch read under one bounded write lock. `bounded_lock` guarantees we never
142    // block the runtime indefinitely and degrade gracefully on contention instead
143    // of hanging; ctx_read's own fast/slow path tolerates this lock being held.
144    let Some(mut cache) = crate::server::bounded_lock::write(cache_lock, "ctx_multi_read:cache")
145    else {
146        return Err(ErrorData::internal_error(
147            "cache write-lock timeout in ctx_multi_read — another tool may be holding it. Retry in a moment.",
148            None,
149        ));
150    };
151    let output = crate::tools::ctx_multi_read::handle_with_task_fresh(
152        &mut cache,
153        &paths,
154        &mode,
155        fresh,
156        ctx.crp_mode,
157        current_task.as_deref(),
158    );
159    let mut total_original: usize = 0;
160    for path in &paths {
161        total_original =
162            total_original.saturating_add(cache.get(path).map_or(0, |e| e.original_tokens));
163    }
164    let tokens = crate::core::tokens::count_tokens(&output);
165    drop(cache);
166
167    Ok(ToolOutput {
168        text: output,
169        original_tokens: total_original,
170        saved_tokens: total_original.saturating_sub(tokens),
171        mode: Some(mode),
172        path: None,
173        changed: false,
174        shell_outcome: None,
175        content_blocks: None,
176    })
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use std::sync::Arc;
183    use std::time::Duration;
184    use tokio::sync::RwLock;
185
186    use crate::core::cache::SessionCache;
187    use crate::core::session::SessionState;
188    use crate::tools::CrpMode;
189
190    fn ctx_with(
191        cache: Arc<RwLock<SessionCache>>,
192        session: Arc<RwLock<SessionState>>,
193        project_root: &str,
194    ) -> ToolContext {
195        ToolContext {
196            project_root: project_root.to_string(),
197            extra_roots: Vec::new(),
198            minimal: false,
199            resolved_paths: std::collections::HashMap::new(),
200            crp_mode: CrpMode::Off,
201            cache: Some(cache),
202            session: Some(session),
203            tool_calls: None,
204            agent_id: None,
205            workflow: None,
206            ledger: None,
207            client_name: None,
208            pipeline_stats: None,
209            call_count: None,
210            autonomy: None,
211            pressure_snapshot: None,
212            path_errors: std::collections::HashMap::new(),
213            bm25_cache: None,
214            progress_sender: None,
215        }
216    }
217
218    /// Regression for #271 (crash vector 11): under concurrent load,
219    /// `ctx_multi_read` must not hang. The handler runs inside the dispatch
220    /// layer's `block_in_place`, so it must acquire its session/cache locks
221    /// via `Handle::block_on` WITHOUT nesting another `block_in_place` —
222    /// nesting consumes extra blocking-pool threads and, under load, exhausts
223    /// the pool, hanging the call (no JSON-RPC response → client "invoke"
224    /// error).
225    ///
226    /// With only 2 worker threads and 8 concurrent batch reads, a nested
227    /// `block_in_place` regression would deadlock the pool and trip the 20s
228    /// timeout below.
229    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
230    async fn concurrent_multi_read_does_not_hang() {
231        let dir = tempfile::tempdir().unwrap();
232        let mut paths = Vec::new();
233        for i in 0..6 {
234            let p = dir.path().join(format!("file_{i}.rs"));
235            std::fs::write(&p, format!("fn f{i}() {{ let _ = {i}; }}\n")).unwrap();
236            paths.push(p.to_string_lossy().to_string());
237        }
238        let root = dir.path().to_string_lossy().to_string();
239
240        let cache: Arc<RwLock<SessionCache>> = Arc::new(RwLock::new(SessionCache::new()));
241        let session = {
242            let mut s = SessionState::new();
243            s.project_root = Some(root.clone());
244            Arc::new(RwLock::new(s))
245        };
246
247        let mut handles = Vec::new();
248        for _ in 0..8 {
249            let cache = cache.clone();
250            let session = session.clone();
251            let paths = paths.clone();
252            let root = root.clone();
253            handles.push(tokio::spawn(async move {
254                let ctx = ctx_with(cache, session, &root);
255                let args = json!({ "paths": paths, "mode": "full" })
256                    .as_object()
257                    .unwrap()
258                    .clone();
259                tokio::task::block_in_place(|| CtxMultiReadTool.handle(&args, &ctx))
260            }));
261        }
262
263        for h in handles {
264            let joined = tokio::time::timeout(Duration::from_secs(20), h)
265                .await
266                .expect("ctx_multi_read hung (>20s) — nested block_in_place regression?")
267                .expect("spawned task panicked");
268            let out = joined.expect("ctx_multi_read returned an error");
269            assert!(
270                out.text.contains("Read 6 files"),
271                "unexpected output: {}",
272                out.text
273            );
274        }
275    }
276
277    /// #509: `ctx_read` with a `paths` array must route to the shared
278    /// `batch_read` (folding `ctx_multi_read` into `ctx_read`), producing the
279    /// same multi-file batch output as calling `ctx_multi_read` directly.
280    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
281    async fn ctx_read_with_paths_delegates_to_batch_read() {
282        use crate::tools::registered::ctx_read::CtxReadTool;
283
284        let dir = tempfile::tempdir().unwrap();
285        let mut paths = Vec::new();
286        for i in 0..3 {
287            let p = dir.path().join(format!("f{i}.rs"));
288            std::fs::write(&p, format!("fn f{i}() {{ let _ = {i}; }}\n")).unwrap();
289            paths.push(p.to_string_lossy().to_string());
290        }
291        let root = dir.path().to_string_lossy().to_string();
292
293        let cache: Arc<RwLock<SessionCache>> = Arc::new(RwLock::new(SessionCache::new()));
294        let session = {
295            let mut s = SessionState::new();
296            s.project_root = Some(root.clone());
297            Arc::new(RwLock::new(s))
298        };
299        let ctx = ctx_with(cache, session, &root);
300        let args = json!({ "paths": paths, "mode": "full" })
301            .as_object()
302            .unwrap()
303            .clone();
304
305        let out = tokio::task::block_in_place(|| CtxReadTool.handle(&args, &ctx))
306            .expect("ctx_read(paths) returned an error");
307        assert!(
308            out.text.contains("Read 3 files"),
309            "ctx_read(paths) must batch-read like ctx_multi_read, got: {}",
310            out.text
311        );
312    }
313
314    /// #421: `ctx_multi_read` used to force `auto`→`full`, so omitting `mode`
315    /// over-expanded every file regardless of the active profile. With no `mode`
316    /// arg the handler must fall back to the profile's effective read mode
317    /// (`auto` by default) and pass it through to `ctx_read` — never silently
318    /// rewrite it to `full`.
319    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
320    async fn omitting_mode_uses_profile_default_not_forced_full() {
321        let dir = tempfile::tempdir().unwrap();
322        let p = dir.path().join("lib.rs");
323        std::fs::write(&p, "fn a() {}\nfn b() {}\n").unwrap();
324        let root = dir.path().to_string_lossy().to_string();
325
326        let cache: Arc<RwLock<SessionCache>> = Arc::new(RwLock::new(SessionCache::new()));
327        let session = {
328            let mut s = SessionState::new();
329            s.project_root = Some(root.clone());
330            Arc::new(RwLock::new(s))
331        };
332        let ctx = ctx_with(cache, session, &root);
333        let args = json!({ "paths": [p.to_string_lossy()] })
334            .as_object()
335            .unwrap()
336            .clone();
337
338        let out = tokio::task::block_in_place(|| CtxMultiReadTool.handle(&args, &ctx))
339            .expect("ctx_multi_read returned an error");
340
341        let expected = crate::core::profiles::active_profile()
342            .read
343            .default_mode_effective()
344            .to_string();
345        assert_eq!(
346            out.mode,
347            Some(expected),
348            "omitting mode must use the profile default, not a forced override (#421)"
349        );
350    }
351}