lean_ctx/tools/registered/
ctx_tree.rs1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_bool, get_int};
6use crate::tool_defs::tool_def;
7
8pub struct CtxTreeTool;
9
10impl McpTool for CtxTreeTool {
11 fn name(&self) -> &'static str {
12 "ctx_tree"
13 }
14
15 fn tool_def(&self) -> Tool {
16 tool_def(
17 "ctx_tree",
18 "Directory tree with file counts per directory. depth=N (default 3);\n\
19 show_hidden for dotfiles; paths for multi-root.\n\
20 respect_gitignore filters ignored files (default true).\n\
21 WORKFLOW: lightweight orientation before ctx_repomap or ctx_compose.",
22 json!({
23 "type": "object",
24 "properties": {
25 "path": { "type": "string", "description": "Dir" },
26 "paths": {
27 "type": "array",
28 "items": { "type": "string" },
29 "description": "Multi-root (alternative to path)"
30 },
31 "depth": { "type": "integer", "description": "Max depth" },
32 "show_hidden": { "type": "boolean", "description": "Include dotfiles" },
33 "respect_gitignore": { "type": "boolean", "description": "Filter ignored" }
34 }
35 }),
36 )
37 }
38
39 fn handle(
40 &self,
41 args: &Map<String, Value>,
42 ctx: &ToolContext,
43 ) -> Result<ToolOutput, ErrorData> {
44 let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
45 .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
46 let depth = (get_int(args, "depth").unwrap_or(3) as usize).min(10);
47 let show_hidden = get_bool(args, "show_hidden").unwrap_or(false);
48 let respect_gitignore = get_bool(args, "respect_gitignore").unwrap_or(true);
49
50 if !resolved.is_multi {
51 return handle_single(&resolved.roots[0], depth, show_hidden, respect_gitignore);
52 }
53
54 let mut combined = String::new();
55 let mut total_original: usize = 0;
56 let mut total_sent: usize = 0;
57
58 for root in &resolved.roots {
59 let root_clone = root.clone();
60 let Ok((result, original)) =
61 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
62 crate::tools::ctx_tree::handle(
63 &root_clone,
64 depth,
65 show_hidden,
66 respect_gitignore,
67 )
68 }))
69 else {
70 combined.push_str(&format!("── {root} ──\nERROR: internal panic\n\n"));
71 continue;
72 };
73
74 if result.starts_with("ERROR:") {
75 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
76 continue;
77 }
78
79 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
80 total_original += original;
81 total_sent += crate::core::tokens::count_tokens(&result);
82 }
83
84 let final_out =
85 crate::core::protocol::append_savings(&combined, total_original, total_sent);
86 let saved = total_original.saturating_sub(total_sent);
87
88 Ok(ToolOutput {
89 text: final_out,
90 original_tokens: total_original,
91 saved_tokens: saved,
92 mode: None,
93 path: None,
94 changed: false,
95 shell_outcome: None,
96 })
97 }
98}
99
100fn handle_single(
101 path: &str,
102 depth: usize,
103 show_hidden: bool,
104 respect_gitignore: bool,
105) -> Result<ToolOutput, ErrorData> {
106 let path_clone = path.to_string();
107 let Ok((result, original)) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
108 crate::tools::ctx_tree::handle(&path_clone, depth, show_hidden, respect_gitignore)
109 })) else {
110 return Err(ErrorData::internal_error(
111 format!(
112 "ctx_tree panicked while processing '{path}'. This is a bug — please report it."
113 ),
114 None,
115 ));
116 };
117
118 if result.starts_with("ERROR:") {
119 return Err(ErrorData::invalid_params(result, None));
120 }
121
122 let sent = crate::core::tokens::count_tokens(&result);
123 let saved = original.saturating_sub(sent);
124 let final_out = crate::core::protocol::append_savings(&result, original, sent);
125
126 Ok(ToolOutput {
127 text: final_out,
128 original_tokens: original,
129 saved_tokens: saved,
130 mode: None,
131 path: Some(path.to_string()),
132 changed: false,
133 shell_outcome: None,
134 })
135}