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