lean_ctx/tools/registered/
ctx_tree.rs1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::core::ocla::cache_types::{CacheKeyBuilder, DirectoryWalkKey};
6use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_bool, get_int};
7use crate::tool_defs::tool_def;
8
9pub struct CtxTreeTool;
10
11impl McpTool for CtxTreeTool {
12 fn name(&self) -> &'static str {
13 "ctx_tree"
14 }
15
16 fn tool_def(&self) -> Tool {
17 tool_def(
18 "ctx_tree",
19 "Directory tree with file counts per directory. depth=N (default 3);\n\
20 show_hidden for dotfiles; paths for multi-root.\n\
21 respect_gitignore filters ignored files (default true).\n\
22 WORKFLOW: lightweight orientation before ctx_repomap or ctx_compose.",
23 json!({
24 "type": "object",
25 "properties": {
26 "path": { "type": "string", "description": "Dir" },
27 "paths": {
28 "type": "array",
29 "items": { "type": "string" },
30 "description": "Multi-root (alternative to path)"
31 },
32 "depth": { "type": "integer", "description": "Max depth" },
33 "show_hidden": { "type": "boolean", "description": "Include dotfiles" },
34 "respect_gitignore": { "type": "boolean", "description": "Filter ignored" }
35 }
36 }),
37 )
38 }
39
40 fn handle(
41 &self,
42 args: &Map<String, Value>,
43 ctx: &ToolContext,
44 ) -> Result<ToolOutput, ErrorData> {
45 let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
46 .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
47 let depth = (get_int(args, "depth").unwrap_or(3) as usize).min(10);
48 let show_hidden = get_bool(args, "show_hidden").unwrap_or(false);
49 let respect_gitignore = get_bool(args, "respect_gitignore").unwrap_or(true);
50
51 let mut combined = String::new();
52 let mut total_original: usize = 0;
53 let mut total_sent: usize = 0;
54
55 for root in &resolved.roots {
56 let root_clone = root.clone();
57 let Ok((result, original)) =
58 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
59 cached_or_walk(&root_clone, depth, show_hidden, respect_gitignore)
60 }))
61 else {
62 combined.push_str(&format!("── {root} ──\nERROR: internal panic\n\n"));
63 continue;
64 };
65
66 if result.starts_with("ERROR:") {
67 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
68 continue;
69 }
70
71 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
72 total_original += original;
73 total_sent += crate::core::tokens::count_tokens(&result);
74 }
75
76 let final_out =
77 crate::core::protocol::append_savings(&combined, total_original, total_sent);
78 let saved = total_original.saturating_sub(total_sent);
79
80 Ok(ToolOutput {
81 text: final_out,
82 original_tokens: total_original,
83 saved_tokens: saved,
84 mode: None,
85 path: None,
86 changed: false,
87 shell_outcome: None,
88 content_blocks: None,
89 })
90 }
91}
92
93fn directory_mtime_ns(path: &std::path::Path) -> Option<u128> {
94 std::fs::metadata(path)
95 .ok()?
96 .modified()
97 .ok()?
98 .duration_since(std::time::UNIX_EPOCH)
99 .ok()
100 .map(|duration| duration.as_nanos())
101}
102
103fn cached_or_walk(
104 path: &str,
105 depth: usize,
106 show_hidden: bool,
107 respect_gitignore: bool,
108) -> (String, usize) {
109 let builder = DirectoryWalkKey {
110 path: crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(path))
111 .to_string_lossy()
112 .into_owned(),
113 depth,
114 gitignore: respect_gitignore,
115 dir_mtime_ns: directory_mtime_ns(&crate::core::pathutil::safe_canonicalize_or_self(
116 std::path::Path::new(path),
117 ))
118 .unwrap_or_default(),
119 };
120 let key = builder.cache_key();
121 if let Some(entry) =
122 crate::core::ocla::cache_delivery::check(&key, &builder.validator(), "ctx_tree")
123 {
124 let stub = crate::core::ocla::cache_delivery::stub(&entry, "directory tree");
125 return (stub, entry.token_count as usize);
126 }
127 let (result, original) =
128 crate::tools::ctx_tree::handle(path, depth, show_hidden, respect_gitignore);
129 if !result.starts_with("ERROR:") {
130 crate::core::ocla::cache_delivery::record(
131 key,
132 crate::core::ocla::cache_types::DeliveryKind::DirectoryWalk,
133 builder.validator(),
134 Some(builder.path),
135 &result,
136 "ctx_tree",
137 );
138 }
139 (result, original)
140}
141
142#[cfg(test)]
143mod tests {
144 use super::cached_or_walk;
145
146 #[test]
147 fn tree_adapter_records_then_serves_a_cross_agent_reference() {
148 let directory = tempfile::tempdir().unwrap();
149 std::fs::write(directory.path().join("cached.rs"), "fn cached() {}\n").unwrap();
150 let path = directory.path().to_string_lossy();
151
152 let (first_result, _first_orig) = cached_or_walk(&path, 3, false, true);
153 assert!(first_result.contains("cached.rs"));
154 let (second_result, _second_orig) = cached_or_walk(&path, 3, false, true);
155 assert!(second_result.contains("[cross-agent"), "{}", second_result);
156 }
157}