Skip to main content

lean_ctx/server/
dynamic_tools.rs

1use std::collections::HashSet;
2use std::sync::{Mutex, OnceLock};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5pub enum ToolCategory {
6    Core,
7    Internal,
8    Arch,
9    Debug,
10    Memory,
11    Metrics,
12    Session,
13}
14
15impl ToolCategory {
16    pub fn parse(s: &str) -> Option<Self> {
17        match s {
18            "core" => Some(Self::Core),
19            "arch" | "architecture" => Some(Self::Arch),
20            "debug" | "profiling" => Some(Self::Debug),
21            "memory" | "semantic" => Some(Self::Memory),
22            "metrics" | "stats" => Some(Self::Metrics),
23            "session" => Some(Self::Session),
24            _ => None,
25        }
26    }
27
28    pub fn as_str(&self) -> &'static str {
29        match self {
30            Self::Core => "core",
31            Self::Internal => "internal",
32            Self::Arch => "arch",
33            Self::Debug => "debug",
34            Self::Memory => "memory",
35            Self::Metrics => "metrics",
36            Self::Session => "session",
37        }
38    }
39}
40
41#[allow(clippy::match_same_arms)]
42pub fn categorize_tool(name: &str) -> ToolCategory {
43    match name {
44        // Internal: meta/self-referential + automated mechanisms (never exposed)
45        "ctx_metrics"
46        | "ctx_cost"
47        | "ctx_gain"
48        | "ctx_radar"
49        | "ctx_heatmap"
50        | "ctx_feedback"
51        | "ctx_intent"
52        | "ctx_response"
53        | "ctx_discover"
54        | "ctx_discover_tools"
55        | "ctx_load_tools"
56        | "ctx_dedup"
57        | "ctx_preload"
58        | "ctx_prefetch"
59        | "ctx_compress_memory" => ToolCategory::Internal,
60
61        // Core: always visible. Must cover every CORE_TOOL_NAMES entry —
62        // otherwise the category gate silently drops a lazy-core tool for
63        // list_changed-capable clients (ctx_expand was lost this way, #575).
64        "ctx_read" | "ctx_search" | "ctx_shell" | "shell" | "ctx_tree" | "ctx_edit"
65        | "ctx_session" | "ctx_checkpoint" | "ctx_knowledge" | "ctx_overview" | "ctx_graph"
66        | "ctx_call" | "ctx_compress" | "ctx_cache" | "ctx_retrieve" | "ctx_expand" => {
67            ToolCategory::Core
68        }
69
70        // Merged tools (redirects in registry, treated as Core for backward compat)
71        "ctx_multi_read" | "ctx_smart_read" | "ctx_delta" | "ctx_outline" | "ctx_context" => {
72            ToolCategory::Core
73        }
74
75        // Arch: on-demand architecture analysis
76        "ctx_architecture" | "ctx_impact" | "ctx_callgraph" | "ctx_refactor" | "ctx_symbol"
77        | "ctx_routes" | "ctx_smells" | "ctx_index" => ToolCategory::Arch,
78
79        // Debug/Verify: on-demand quality analysis
80        "ctx_benchmark" | "ctx_verify" | "ctx_analyze" | "ctx_profile" | "ctx_proof"
81        | "ctx_review" => ToolCategory::Debug,
82
83        // Provider + URL/Git readers + the MCP gateway are Core: gateways to
84        // external context (GitHub issues, Jira, Postgres, web pages, YouTube,
85        // remote git repos, downstream MCP servers) — always available.
86        // ctx_semantic_search is a first-class retrieval tool (advertised in the
87        // lean core, #422) — keep it Core so the default category gate never hides
88        // it, the very reason agents stopped reaching for it.
89        "ctx_provider" | "ctx_url_read" | "ctx_git_read" | "ctx_tools" | "ctx_semantic_search" => {
90            ToolCategory::Core
91        }
92
93        // Memory: on-demand artifact retrieval
94        "ctx_artifacts" => ToolCategory::Memory,
95
96        // Batch: on-demand batch/PR/sandbox tools
97        "ctx_fill" | "ctx_execute" | "ctx_pack" | "ctx_plan" | "ctx_control" | "ctx_compile" => {
98            ToolCategory::Metrics
99        }
100
101        // Multi-agent: on-demand collaboration
102        "ctx_agent" | "ctx_share" | "ctx_task" | "ctx_handoff" | "ctx_workflow" => {
103            ToolCategory::Session
104        }
105
106        _ => ToolCategory::Core,
107    }
108}
109
110pub fn is_readonly_tool(name: &str) -> bool {
111    matches!(
112        name,
113        "ctx_read"
114            | "ctx_search"
115            | "ctx_tree"
116            | "ctx_overview"
117            | "ctx_plan"
118            | "ctx_metrics"
119            | "ctx_compress"
120            | "ctx_session"
121            | "ctx_knowledge"
122            | "ctx_graph"
123            | "ctx_retrieve"
124            | "ctx_provider"
125            | "ctx_multi_read"
126            | "ctx_smart_read"
127            | "ctx_delta"
128            | "ctx_outline"
129            | "ctx_context"
130            | "ctx_call"
131            | "ctx_url_read"
132            | "ctx_git_read"
133            | "ctx_architecture"
134            | "ctx_impact"
135            | "ctx_callgraph"
136            | "ctx_symbol"
137            | "ctx_routes"
138            | "ctx_smells"
139            | "ctx_index"
140            | "ctx_semantic_search"
141            | "ctx_artifacts"
142            | "ctx_cost"
143            | "ctx_gain"
144            | "ctx_heatmap"
145    )
146}
147
148#[derive(Debug)]
149pub struct DynamicToolState {
150    active_categories: HashSet<ToolCategory>,
151    supports_list_changed: bool,
152}
153
154impl Default for DynamicToolState {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160impl DynamicToolState {
161    pub fn new() -> Self {
162        let mut active = HashSet::new();
163        active.insert(ToolCategory::Core);
164        active.insert(ToolCategory::Session);
165        Self {
166            active_categories: active,
167            supports_list_changed: false,
168        }
169    }
170
171    /// Creates state with categories from config (env var > config.toml > default).
172    pub fn from_config(categories: &[String]) -> Self {
173        let mut active = HashSet::new();
174        active.insert(ToolCategory::Core);
175        for cat_str in categories {
176            if let Some(cat) = ToolCategory::parse(cat_str) {
177                active.insert(cat);
178            }
179        }
180        Self {
181            active_categories: active,
182            supports_list_changed: false,
183        }
184    }
185
186    pub fn all_enabled() -> Self {
187        let mut active = HashSet::new();
188        active.insert(ToolCategory::Core);
189        active.insert(ToolCategory::Arch);
190        active.insert(ToolCategory::Debug);
191        active.insert(ToolCategory::Memory);
192        active.insert(ToolCategory::Metrics);
193        active.insert(ToolCategory::Session);
194        Self {
195            active_categories: active,
196            supports_list_changed: false,
197        }
198    }
199
200    pub fn set_supports_list_changed(&mut self, val: bool) {
201        self.supports_list_changed = val;
202    }
203
204    pub fn supports_list_changed(&self) -> bool {
205        self.supports_list_changed
206    }
207
208    pub fn load_category(&mut self, cat: ToolCategory) -> bool {
209        self.active_categories.insert(cat)
210    }
211
212    pub fn unload_category(&mut self, cat: ToolCategory) -> bool {
213        if cat == ToolCategory::Core || cat == ToolCategory::Internal {
214            return false;
215        }
216        self.active_categories.remove(&cat)
217    }
218
219    pub fn is_tool_active(&self, name: &str) -> bool {
220        let cat = categorize_tool(name);
221        if cat == ToolCategory::Internal {
222            return false;
223        }
224        if !self.supports_list_changed {
225            return true;
226        }
227        self.active_categories.contains(&cat)
228    }
229
230    pub fn active_categories(&self) -> Vec<&'static str> {
231        let mut cats: Vec<_> = self
232            .active_categories
233            .iter()
234            .map(ToolCategory::as_str)
235            .collect();
236        cats.sort_unstable();
237        cats
238    }
239
240    pub fn all_categories() -> Vec<&'static str> {
241        vec!["core", "arch", "debug", "memory", "metrics", "session"]
242    }
243}
244
245static GLOBAL: OnceLock<Mutex<DynamicToolState>> = OnceLock::new();
246
247pub fn global() -> &'static Mutex<DynamicToolState> {
248    GLOBAL.get_or_init(|| Mutex::new(DynamicToolState::new()))
249}
250
251pub fn init_all_enabled() {
252    let _ = GLOBAL.set(Mutex::new(DynamicToolState::all_enabled()));
253}
254
255/// Initializes the global state from user config (env var > config.toml > default).
256/// Call once during server startup after config is loaded.
257/// If the global was already initialized (e.g. by a concurrent `global()` call),
258/// applies the categories to the existing state instead.
259pub fn init_from_config(categories: &[String]) {
260    if GLOBAL
261        .set(Mutex::new(DynamicToolState::from_config(categories)))
262        .is_err()
263    {
264        if let Ok(mut state) = global().lock() {
265            let desired = DynamicToolState::from_config(categories);
266            *state = desired;
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn core_tools_always_active() {
277        let state = DynamicToolState::new();
278        assert!(state.is_tool_active("ctx_read"));
279        assert!(state.is_tool_active("ctx_search"));
280    }
281
282    #[test]
283    fn dynamic_tools_filtered_when_list_changed() {
284        let mut state = DynamicToolState::new();
285        state.set_supports_list_changed(true);
286        assert!(!state.is_tool_active("ctx_benchmark"));
287        assert!(!state.is_tool_active("ctx_architecture"));
288        assert!(state.is_tool_active("ctx_read"));
289    }
290
291    #[test]
292    fn load_category_enables_tools() {
293        let mut state = DynamicToolState::new();
294        state.set_supports_list_changed(true);
295        assert!(!state.is_tool_active("ctx_architecture"));
296        state.load_category(ToolCategory::Arch);
297        assert!(state.is_tool_active("ctx_architecture"));
298    }
299
300    #[test]
301    fn cannot_unload_core() {
302        let mut state = DynamicToolState::new();
303        assert!(!state.unload_category(ToolCategory::Core));
304    }
305
306    #[test]
307    fn all_tools_visible_without_list_changed() {
308        let state = DynamicToolState::new();
309        assert!(state.is_tool_active("ctx_graph"));
310        assert!(!state.is_tool_active("ctx_metrics")); // Internal tools never active
311    }
312
313    #[test]
314    fn internal_tools_never_active() {
315        let state = DynamicToolState::all_enabled();
316        assert!(!state.is_tool_active("ctx_metrics"));
317        assert!(!state.is_tool_active("ctx_cost"));
318        assert!(!state.is_tool_active("ctx_discover_tools"));
319        assert!(!state.is_tool_active("ctx_dedup"));
320    }
321
322    // --- from_config: basic scenarios ---
323
324    #[test]
325    fn from_config_core_arch_memory() {
326        let cats = vec!["core".to_string(), "arch".to_string(), "memory".to_string()];
327        let mut state = DynamicToolState::from_config(&cats);
328        state.set_supports_list_changed(true);
329        assert!(state.is_tool_active("ctx_read"));
330        assert!(state.is_tool_active("ctx_architecture"));
331        assert!(state.is_tool_active("ctx_artifacts"));
332        assert!(!state.is_tool_active("ctx_benchmark"));
333        assert!(!state.is_tool_active("ctx_fill"));
334    }
335
336    #[test]
337    fn from_config_empty_still_has_core() {
338        let mut state = DynamicToolState::from_config(&[]);
339        state.set_supports_list_changed(true);
340        assert!(state.is_tool_active("ctx_read"));
341        assert!(!state.is_tool_active("ctx_architecture"));
342        assert!(!state.is_tool_active("ctx_benchmark"));
343        assert!(!state.is_tool_active("ctx_artifacts"));
344    }
345
346    // --- from_config: all categories ---
347
348    #[test]
349    fn from_config_all_categories_enables_everything_except_internal() {
350        let cats = vec![
351            "core".to_string(),
352            "arch".to_string(),
353            "debug".to_string(),
354            "memory".to_string(),
355            "metrics".to_string(),
356            "session".to_string(),
357        ];
358        let mut state = DynamicToolState::from_config(&cats);
359        state.set_supports_list_changed(true);
360        assert!(state.is_tool_active("ctx_read"));
361        assert!(state.is_tool_active("ctx_architecture"));
362        assert!(state.is_tool_active("ctx_benchmark"));
363        assert!(state.is_tool_active("ctx_semantic_search"));
364        assert!(state.is_tool_active("ctx_fill"));
365        assert!(state.is_tool_active("ctx_workflow"));
366        assert!(!state.is_tool_active("ctx_metrics"));
367    }
368
369    // --- from_config: single category ---
370
371    #[test]
372    fn from_config_only_debug() {
373        let cats = vec!["debug".to_string()];
374        let mut state = DynamicToolState::from_config(&cats);
375        state.set_supports_list_changed(true);
376        assert!(state.is_tool_active("ctx_read"));
377        assert!(state.is_tool_active("ctx_benchmark"));
378        assert!(!state.is_tool_active("ctx_architecture"));
379        assert!(!state.is_tool_active("ctx_workflow"));
380    }
381
382    // --- from_config: invalid categories are silently ignored ---
383
384    #[test]
385    fn from_config_ignores_unknown_categories() {
386        let cats = vec![
387            "core".to_string(),
388            "nonexistent".to_string(),
389            "foobar".to_string(),
390        ];
391        let mut state = DynamicToolState::from_config(&cats);
392        state.set_supports_list_changed(true);
393        assert!(state.is_tool_active("ctx_read"));
394        assert!(!state.is_tool_active("ctx_architecture"));
395    }
396
397    #[test]
398    fn from_config_only_invalid_still_has_core() {
399        let cats = vec!["invalid".to_string(), "bogus".to_string()];
400        let mut state = DynamicToolState::from_config(&cats);
401        state.set_supports_list_changed(true);
402        assert!(state.is_tool_active("ctx_read"));
403        assert!(!state.is_tool_active("ctx_benchmark"));
404    }
405
406    // --- from_config: duplicate categories are idempotent ---
407
408    #[test]
409    fn from_config_duplicates_are_harmless() {
410        let cats = vec!["arch".to_string(), "arch".to_string(), "arch".to_string()];
411        let mut state = DynamicToolState::from_config(&cats);
412        state.set_supports_list_changed(true);
413        assert!(state.is_tool_active("ctx_architecture"));
414        let active = state.active_categories();
415        let arch_count = active.iter().filter(|&&c| c == "arch").count();
416        assert_eq!(arch_count, 1);
417    }
418
419    // --- from_config: internal category is never user-activatable ---
420
421    #[test]
422    fn from_config_internal_category_not_parseable() {
423        assert!(ToolCategory::parse("internal").is_none());
424    }
425
426    // --- from_config: category aliases work ---
427
428    #[test]
429    fn from_config_alias_architecture_maps_to_arch() {
430        let cats = vec!["architecture".to_string()];
431        let mut state = DynamicToolState::from_config(&cats);
432        state.set_supports_list_changed(true);
433        assert!(state.is_tool_active("ctx_architecture"));
434    }
435
436    #[test]
437    fn from_config_alias_profiling_maps_to_debug() {
438        let cats = vec!["profiling".to_string()];
439        let mut state = DynamicToolState::from_config(&cats);
440        state.set_supports_list_changed(true);
441        assert!(state.is_tool_active("ctx_benchmark"));
442    }
443
444    #[test]
445    fn from_config_alias_semantic_maps_to_memory() {
446        let cats = vec!["semantic".to_string()];
447        let mut state = DynamicToolState::from_config(&cats);
448        state.set_supports_list_changed(true);
449        assert!(state.is_tool_active("ctx_artifacts"));
450    }
451
452    // --- from_config: subsequent load/unload still works ---
453
454    #[test]
455    fn from_config_then_load_additional_category() {
456        let cats = vec!["core".to_string()];
457        let mut state = DynamicToolState::from_config(&cats);
458        state.set_supports_list_changed(true);
459        assert!(!state.is_tool_active("ctx_architecture"));
460        state.load_category(ToolCategory::Arch);
461        assert!(state.is_tool_active("ctx_architecture"));
462    }
463
464    #[test]
465    fn from_config_then_unload_non_core_category() {
466        let cats = vec!["core".to_string(), "arch".to_string()];
467        let mut state = DynamicToolState::from_config(&cats);
468        state.set_supports_list_changed(true);
469        assert!(state.is_tool_active("ctx_architecture"));
470        state.unload_category(ToolCategory::Arch);
471        assert!(!state.is_tool_active("ctx_architecture"));
472    }
473
474    #[test]
475    fn from_config_cannot_unload_core() {
476        let cats = vec!["core".to_string(), "arch".to_string()];
477        let mut state = DynamicToolState::from_config(&cats);
478        assert!(!state.unload_category(ToolCategory::Core));
479    }
480
481    // --- from_config: without list_changed, all tools visible ---
482
483    #[test]
484    fn from_config_without_list_changed_shows_all() {
485        let cats = vec!["core".to_string()];
486        let state = DynamicToolState::from_config(&cats);
487        assert!(state.is_tool_active("ctx_architecture"));
488        assert!(state.is_tool_active("ctx_benchmark"));
489        assert!(!state.is_tool_active("ctx_metrics"));
490    }
491
492    #[test]
493    fn lazy_core_tools_survive_default_category_gate() {
494        // Regression for #575: every advertised lazy-core tool must stay
495        // active under the default category gate (Core + Session) when the
496        // client supports list_changed — otherwise Cursor silently loses
497        // tools like ctx_expand from the 13-tool core set.
498        let mut state = DynamicToolState::new();
499        state.set_supports_list_changed(true);
500        for name in crate::tool_defs::core_tool_names() {
501            assert!(
502                state.is_tool_active(name),
503                "{name} is in CORE_TOOL_NAMES but dropped by the default category gate"
504            );
505        }
506    }
507
508    #[test]
509    fn categorize_known_tools() {
510        assert_eq!(categorize_tool("ctx_read"), ToolCategory::Core);
511        assert_eq!(categorize_tool("ctx_graph"), ToolCategory::Core);
512        assert_eq!(categorize_tool("ctx_benchmark"), ToolCategory::Debug);
513        assert_eq!(categorize_tool("ctx_semantic_search"), ToolCategory::Core);
514        assert_eq!(categorize_tool("ctx_artifacts"), ToolCategory::Memory);
515        assert_eq!(categorize_tool("ctx_metrics"), ToolCategory::Internal);
516        assert_eq!(categorize_tool("ctx_workflow"), ToolCategory::Session);
517    }
518
519    #[test]
520    fn readonly_classification() {
521        assert!(is_readonly_tool("ctx_read"));
522        assert!(is_readonly_tool("ctx_search"));
523        assert!(is_readonly_tool("ctx_tree"));
524        assert!(is_readonly_tool("ctx_overview"));
525        assert!(is_readonly_tool("ctx_provider"));
526
527        assert!(!is_readonly_tool("ctx_edit"));
528        assert!(!is_readonly_tool("ctx_shell"));
529        assert!(!is_readonly_tool("ctx_compile"));
530        assert!(!is_readonly_tool("ctx_execute"));
531        assert!(!is_readonly_tool("ctx_cache"));
532    }
533
534    #[test]
535    fn plan_mode_tools_are_all_readonly() {
536        for tool in crate::core::editor_registry::plan_mode::plan_mode_tools() {
537            assert!(
538                is_readonly_tool(tool),
539                "{tool} is listed as plan mode tool but not marked readonly"
540            );
541        }
542    }
543}