Skip to main content

lean_ctx/tools/registered/
ctx_tree.rs

1use 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}