1use super::dynamic_tools::{ToolCategory, categorize_tool};
14use crate::core::tool_profiles::ToolProfile;
15
16pub const INVOKER: &str = "ctx_call";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum CandidateSet {
23 Full,
25 Unified,
27 ProfileAuthoritative,
30 LazyCore,
33}
34
35#[must_use]
39pub fn candidate_set(full_mode: bool, unified_env: bool, explicit_profile: bool) -> CandidateSet {
40 if full_mode {
41 CandidateSet::Full
42 } else if unified_env {
43 CandidateSet::Unified
44 } else if explicit_profile {
45 CandidateSet::ProfileAuthoritative
46 } else {
47 CandidateSet::LazyCore
48 }
49}
50
51#[must_use]
54pub fn explicit_profile(cfg: &crate::core::config::Config) -> bool {
55 cfg.tool_profile.is_some()
56 || !cfg.tools_enabled.is_empty()
57 || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
58}
59
60#[must_use]
66pub fn is_tool_visible(
67 name: &str,
68 profile: &ToolProfile,
69 disabled: &[String],
70 is_zed: bool,
71 role_allows: bool,
72) -> bool {
73 if categorize_tool(name) == ToolCategory::Internal {
74 return false;
75 }
76 if super::dynamic_tools::is_deprecated_alias(name) {
79 return false;
80 }
81 if !profile.is_tool_enabled(name) {
82 return false;
83 }
84 if disabled.iter().any(|d| d == name) {
85 return false;
86 }
87 if is_zed && name == "ctx_edit" {
88 return false;
89 }
90 role_allows
91}
92
93#[must_use]
99pub fn advertised_tool_defs_default() -> Vec<rmcp::model::Tool> {
100 let cfg = crate::core::config::Config::load();
101 let disabled = cfg.disabled_tools_effective();
102 let profile = cfg.tool_profile_effective();
103 let full_mode = crate::tool_defs::is_full_mode();
104 let registry = crate::server::registry::build_registry();
105
106 let candidate = candidate_set(
107 full_mode,
108 std::env::var("LEAN_CTX_UNIFIED").is_ok(),
109 explicit_profile(&cfg),
110 );
111 let pool: Vec<rmcp::model::Tool> = match candidate {
112 CandidateSet::Full | CandidateSet::ProfileAuthoritative => registry.tool_defs(),
113 CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
114 CandidateSet::LazyCore => {
115 let core = crate::tool_defs::core_tool_names();
116 registry
117 .tool_defs()
118 .into_iter()
119 .filter(|t| core.contains(&t.name.as_ref()))
120 .collect()
121 }
122 };
123
124 let mut tools: Vec<_> = pool
125 .into_iter()
126 .filter(|t| is_tool_visible(t.name.as_ref(), &profile, &disabled, false, true))
127 .collect();
128
129 let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
130 if needs_invoker(full_mode, already, true, &disabled)
131 && let Some(def) = registry
132 .tool_defs()
133 .into_iter()
134 .find(|t| t.name.as_ref() == INVOKER)
135 {
136 tools.push(def);
137 }
138
139 let level = crate::core::config::CompressionLevel::effective(&cfg);
140 let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
141 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
142 return tools;
143 }
144 tools
145 .into_iter()
146 .map(|mut t| {
147 let compressed = crate::core::terse::mcp_compress::compress_description(
148 t.name.as_ref(),
149 t.description.as_deref().unwrap_or(""),
150 mode,
151 );
152 t.description = Some(compressed.into());
153 t
154 })
155 .collect()
156}
157
158#[must_use]
168pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
169 supports_list_changed && !explicit_profile
170}
171
172#[must_use]
178pub fn needs_invoker(
179 full_mode: bool,
180 already_present: bool,
181 invoker_role_allowed: bool,
182 disabled: &[String],
183) -> bool {
184 !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn internal_tools_never_visible_even_in_power() {
193 let p = ToolProfile::Power;
195 assert!(!is_tool_visible("ctx_metrics", &p, &[], false, true));
196 assert!(!is_tool_visible("ctx_cost", &p, &[], false, true));
197 assert!(!is_tool_visible("ctx_discover_tools", &p, &[], false, true));
198 }
199
200 #[test]
201 fn deprecated_aliases_never_visible_even_in_power() {
202 let p = ToolProfile::Power;
205 assert!(!is_tool_visible("ctx_smart_read", &p, &[], false, true));
206 assert!(!is_tool_visible("ctx_multi_read", &p, &[], false, true));
207 }
208
209 #[test]
210 fn deprecated_aliases_stay_registered_and_callable() {
211 let _guard = crate::core::data_dir::isolated_data_dir();
215 let defs = crate::server::registry::build_registry().tool_defs();
216 for name in ["ctx_smart_read", "ctx_multi_read"] {
217 assert!(
218 defs.iter().any(|t| t.name.as_ref() == name),
219 "{name} must stay registered (callable) even though hidden"
220 );
221 assert!(
222 !is_tool_visible(name, &ToolProfile::Power, &[], false, true),
223 "{name} must be hidden from tools/list"
224 );
225 }
226 }
227
228 #[test]
229 fn core_tool_visible_under_power() {
230 assert!(is_tool_visible(
231 "ctx_read",
232 &ToolProfile::Power,
233 &[],
234 false,
235 true
236 ));
237 }
238
239 #[test]
240 fn standard_exposes_its_advertised_tools() {
241 let p = ToolProfile::Standard;
245 assert!(is_tool_visible("ctx_execute", &p, &[], false, true));
246 assert!(is_tool_visible("ctx_semantic_search", &p, &[], false, true));
247 assert!(is_tool_visible("ctx_callgraph", &p, &[], false, true));
248 assert!(is_tool_visible("ctx_graph", &p, &[], false, true));
249 }
250
251 #[test]
252 fn minimal_hides_non_minimal_tools() {
253 let p = ToolProfile::Minimal;
254 assert!(is_tool_visible("ctx_read", &p, &[], false, true));
255 assert!(!is_tool_visible("ctx_architecture", &p, &[], false, true));
256 }
257
258 #[test]
259 fn disabled_list_filters() {
260 let disabled = vec!["ctx_read".to_string()];
261 assert!(!is_tool_visible(
262 "ctx_read",
263 &ToolProfile::Power,
264 &disabled,
265 false,
266 true
267 ));
268 }
269
270 #[test]
271 fn zed_hides_ctx_edit_only() {
272 let p = ToolProfile::Power;
273 assert!(!is_tool_visible("ctx_edit", &p, &[], true, true));
274 assert!(is_tool_visible("ctx_read", &p, &[], true, true));
275 }
276
277 #[test]
278 fn role_block_hides_tool() {
279 assert!(!is_tool_visible(
280 "ctx_read",
281 &ToolProfile::Power,
282 &[],
283 false,
284 false
285 ));
286 }
287
288 #[test]
289 fn category_gate_only_in_default_lean_mode() {
290 assert!(category_gate_applies(true, false));
293 assert!(!category_gate_applies(true, true));
295 assert!(!category_gate_applies(false, false));
297 assert!(!category_gate_applies(false, true));
298 }
299
300 #[test]
301 fn invoker_added_when_missing_in_lazy_mode() {
302 assert!(needs_invoker(false, false, true, &[]));
303 }
304
305 #[test]
306 fn invoker_not_added_in_full_mode() {
307 assert!(!needs_invoker(true, false, true, &[]));
308 }
309
310 #[test]
311 fn invoker_not_duplicated_when_present() {
312 assert!(!needs_invoker(false, true, true, &[]));
313 }
314
315 #[test]
316 fn invoker_respects_role_and_disabled() {
317 assert!(!needs_invoker(false, false, false, &[]));
318 assert!(!needs_invoker(
319 false,
320 false,
321 true,
322 &["ctx_call".to_string()]
323 ));
324 }
325
326 #[test]
357 fn core_tool_surface_stays_within_budget() {
358 const PER_TOOL_BUDGET: usize = 360;
359 const TOTAL_BUDGET: usize = 2340;
360
361 let _guard = crate::core::data_dir::isolated_data_dir();
362 let core = crate::tool_defs::core_tool_names();
363 let defs: Vec<_> = crate::server::registry::build_registry()
364 .tool_defs()
365 .into_iter()
366 .filter(|t| core.contains(&t.name.as_ref()))
367 .collect();
368 assert_eq!(defs.len(), core.len(), "every core tool must be registered");
369
370 let mut total = 0usize;
371 for t in &defs {
372 let desc = t.description.as_deref().unwrap_or("");
373 let schema = serde_json::to_string(&t.input_schema).unwrap_or_default();
374 let cost = crate::core::tokens::count_tokens(desc)
375 + crate::core::tokens::count_tokens(&schema);
376 eprintln!("{:24} {cost:4} tok", t.name.as_ref());
377 assert!(
378 cost <= PER_TOOL_BUDGET,
379 "{} costs {cost} tok (budget {PER_TOOL_BUDGET}) — trim its description/schema",
380 t.name
381 );
382 total += cost;
383 }
384 eprintln!("CORE TOTAL: {total} tok / {} tools", defs.len());
385 assert!(
386 total <= TOTAL_BUDGET,
387 "core surface costs {total} tok (budget {TOTAL_BUDGET})"
388 );
389 }
390}