Skip to main content

lean_ctx_server/tools/registered/
ctx_tree.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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    // GH #1461: The sandboxed LaunchAgent daemon cannot stat paths under
117    // ~/Documents, ~/Desktop, ~/Downloads (TCC). Bail early so the caller
118    // can fall through to a non-sandboxed process (CLI local or MCP server).
119    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        // Footer is appended by append_combined_footer via protocol::append_savings
171
172        let output = append_combined_footer(body, raw_tokens, sent_tokens);
173
174        // Check exactly one savings line exists
175        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        // A protection policy set by a concurrent test withholds the cached
185        // reference; hold the community policy like the search counterpart.
186        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}