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