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_quality" | "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" | "ctx_compare" => 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
110/// A deprecated tool that has been folded into a primary tool (#509 Phase 1).
111///
112/// The alias stays **registered and callable** (directly and via `ctx_call`) for
113/// one release so nothing breaks, but it is hidden from `tools/list` and warns on
114/// use, steering agents to the consolidated primary. Removal happens in Phase 2.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct DeprecatedAlias {
117    /// The primary tool that supersedes this alias (e.g. `"ctx_read"`).
118    pub replacement: &'static str,
119    /// One-line migration hint (e.g. how the primary covers this use case).
120    pub hint: &'static str,
121}
122
123/// Single source of truth for read-cluster deprecations (#509). Returns the
124/// replacement + migration hint when `name` is a deprecated alias, else `None`.
125///
126/// Used by [`crate::server::tool_visibility::is_tool_visible`] to hide the alias
127/// from `tools/list`, and by the dispatch layer to prepend a one-line
128/// deprecation notice to the alias's output. Keeping both behaviours keyed off
129/// this one function guarantees "hidden" and "warned" can never drift apart.
130#[must_use]
131pub fn deprecated_alias(name: &str) -> Option<DeprecatedAlias> {
132    match name {
133        "ctx_smart_read" => Some(DeprecatedAlias {
134            replacement: "ctx_read",
135            hint: "ctx_read auto-selects the mode (omit `mode`, or pass mode=\"auto\")",
136        }),
137        "ctx_multi_read" => Some(DeprecatedAlias {
138            replacement: "ctx_read",
139            hint: "ctx_read now batch-reads via paths=[\"a.rs\",\"b.rs\"]",
140        }),
141        // #509 search consolidation: one ctx_search entry, `action` picks the
142        // engine. Aliases stay callable for one release so nothing breaks.
143        "ctx_semantic_search" => Some(DeprecatedAlias {
144            replacement: "ctx_search",
145            hint: "ctx_search with action=\"semantic\" (query=…); reindex/find_related are actions too",
146        }),
147        "ctx_symbol" => Some(DeprecatedAlias {
148            replacement: "ctx_search",
149            hint: "ctx_search with action=\"symbol\" (name=…, optional file/kind)",
150        }),
151        _ => None,
152    }
153}
154
155/// Whether `name` is a deprecated alias hidden from `tools/list` (#509).
156#[must_use]
157pub fn is_deprecated_alias(name: &str) -> bool {
158    deprecated_alias(name).is_some()
159}
160
161/// The one-line deprecation notice prepended to a deprecated alias's output.
162/// Stable per tool (no timestamps/counters) so provider-side prompt caching
163/// stays byte-stable (#498).
164#[must_use]
165pub fn deprecation_notice(name: &str) -> Option<String> {
166    deprecated_alias(name).map(|d| {
167        format!(
168            "[DEPRECATED] {name} is superseded by {} — {}. This alias is hidden from \
169             tools/list and will be removed in a future release.",
170            d.replacement, d.hint
171        )
172    })
173}
174
175pub fn is_readonly_tool(name: &str) -> bool {
176    matches!(
177        name,
178        "ctx_read"
179            | "ctx_search"
180            | "ctx_tree"
181            | "ctx_overview"
182            | "ctx_plan"
183            | "ctx_metrics"
184            | "ctx_compress"
185            | "ctx_session"
186            | "ctx_knowledge"
187            | "ctx_graph"
188            | "ctx_retrieve"
189            | "ctx_provider"
190            | "ctx_multi_read"
191            | "ctx_smart_read"
192            | "ctx_delta"
193            | "ctx_outline"
194            | "ctx_context"
195            | "ctx_call"
196            | "ctx_url_read"
197            | "ctx_git_read"
198            | "ctx_architecture"
199            | "ctx_impact"
200            | "ctx_callgraph"
201            | "ctx_symbol"
202            | "ctx_routes"
203            | "ctx_smells"
204            | "ctx_quality"
205            | "ctx_index"
206            | "ctx_semantic_search"
207            | "ctx_explore"
208            | "ctx_artifacts"
209            | "ctx_cost"
210            | "ctx_gain"
211            | "ctx_heatmap"
212            | "ctx_compare"
213    )
214}
215
216#[derive(Debug)]
217pub struct DynamicToolState {
218    active_categories: HashSet<ToolCategory>,
219    supports_list_changed: bool,
220}
221
222impl Default for DynamicToolState {
223    fn default() -> Self {
224        Self::new()
225    }
226}
227
228impl DynamicToolState {
229    pub fn new() -> Self {
230        let mut active = HashSet::new();
231        active.insert(ToolCategory::Core);
232        active.insert(ToolCategory::Session);
233        Self {
234            active_categories: active,
235            supports_list_changed: false,
236        }
237    }
238
239    /// Creates state with categories from config (env var > config.toml > default).
240    pub fn from_config(categories: &[String]) -> Self {
241        let mut active = HashSet::new();
242        active.insert(ToolCategory::Core);
243        for cat_str in categories {
244            if let Some(cat) = ToolCategory::parse(cat_str) {
245                active.insert(cat);
246            }
247        }
248        Self {
249            active_categories: active,
250            supports_list_changed: false,
251        }
252    }
253
254    pub fn all_enabled() -> Self {
255        let mut active = HashSet::new();
256        active.insert(ToolCategory::Core);
257        active.insert(ToolCategory::Arch);
258        active.insert(ToolCategory::Debug);
259        active.insert(ToolCategory::Memory);
260        active.insert(ToolCategory::Metrics);
261        active.insert(ToolCategory::Session);
262        Self {
263            active_categories: active,
264            supports_list_changed: false,
265        }
266    }
267
268    pub fn set_supports_list_changed(&mut self, val: bool) {
269        self.supports_list_changed = val;
270    }
271
272    pub fn supports_list_changed(&self) -> bool {
273        self.supports_list_changed
274    }
275
276    pub fn load_category(&mut self, cat: ToolCategory) -> bool {
277        self.active_categories.insert(cat)
278    }
279
280    pub fn unload_category(&mut self, cat: ToolCategory) -> bool {
281        if cat == ToolCategory::Core || cat == ToolCategory::Internal {
282            return false;
283        }
284        self.active_categories.remove(&cat)
285    }
286
287    pub fn is_tool_active(&self, name: &str) -> bool {
288        let cat = categorize_tool(name);
289        if cat == ToolCategory::Internal {
290            return false;
291        }
292        if !self.supports_list_changed {
293            return true;
294        }
295        self.active_categories.contains(&cat)
296    }
297
298    pub fn active_categories(&self) -> Vec<&'static str> {
299        let mut cats: Vec<_> = self
300            .active_categories
301            .iter()
302            .map(ToolCategory::as_str)
303            .collect();
304        cats.sort_unstable();
305        cats
306    }
307
308    pub fn all_categories() -> Vec<&'static str> {
309        vec!["core", "arch", "debug", "memory", "metrics", "session"]
310    }
311}
312
313static GLOBAL: OnceLock<Mutex<DynamicToolState>> = OnceLock::new();
314
315pub fn global() -> &'static Mutex<DynamicToolState> {
316    GLOBAL.get_or_init(|| Mutex::new(DynamicToolState::new()))
317}
318
319pub fn init_all_enabled() {
320    let _ = GLOBAL.set(Mutex::new(DynamicToolState::all_enabled()));
321}
322
323/// Initializes the global state from user config (env var > config.toml > default).
324/// Call once during server startup after config is loaded.
325/// If the global was already initialized (e.g. by a concurrent `global()` call),
326/// applies the categories to the existing state instead.
327pub fn init_from_config(categories: &[String]) {
328    if GLOBAL
329        .set(Mutex::new(DynamicToolState::from_config(categories)))
330        .is_err()
331        && let Ok(mut state) = global().lock()
332    {
333        let desired = DynamicToolState::from_config(categories);
334        *state = desired;
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn core_tools_always_active() {
344        let state = DynamicToolState::new();
345        assert!(state.is_tool_active("ctx_read"));
346        assert!(state.is_tool_active("ctx_search"));
347    }
348
349    #[test]
350    fn deprecated_alias_maps_read_cluster_to_ctx_read() {
351        // #509: only the folded read-cluster tools are deprecated; everything
352        // else (incl. the primary) returns None.
353        assert_eq!(
354            deprecated_alias("ctx_smart_read").unwrap().replacement,
355            "ctx_read"
356        );
357        assert_eq!(
358            deprecated_alias("ctx_multi_read").unwrap().replacement,
359            "ctx_read"
360        );
361        assert!(deprecated_alias("ctx_read").is_none());
362        assert!(deprecated_alias("ctx_search").is_none());
363        assert!(is_deprecated_alias("ctx_multi_read"));
364        assert!(!is_deprecated_alias("ctx_read"));
365
366        // #509 search consolidation: the folded search tools point at ctx_search.
367        assert_eq!(
368            deprecated_alias("ctx_semantic_search").unwrap().replacement,
369            "ctx_search"
370        );
371        assert_eq!(
372            deprecated_alias("ctx_symbol").unwrap().replacement,
373            "ctx_search"
374        );
375        assert!(is_deprecated_alias("ctx_symbol"));
376    }
377
378    #[test]
379    fn deprecation_notice_is_stable_and_names_replacement() {
380        // Stable text (no timestamps/counters) for cache-byte-stability (#498),
381        // and it must point at the primary so agents know where to go.
382        let notice = deprecation_notice("ctx_multi_read").unwrap();
383        assert!(notice.starts_with("[DEPRECATED] ctx_multi_read is superseded by ctx_read"));
384        assert!(notice.contains("paths="));
385        assert_eq!(notice, deprecation_notice("ctx_multi_read").unwrap());
386        assert!(deprecation_notice("ctx_read").is_none());
387    }
388
389    #[test]
390    fn dynamic_tools_filtered_when_list_changed() {
391        let mut state = DynamicToolState::new();
392        state.set_supports_list_changed(true);
393        assert!(!state.is_tool_active("ctx_benchmark"));
394        assert!(!state.is_tool_active("ctx_architecture"));
395        assert!(state.is_tool_active("ctx_read"));
396    }
397
398    #[test]
399    fn load_category_enables_tools() {
400        let mut state = DynamicToolState::new();
401        state.set_supports_list_changed(true);
402        assert!(!state.is_tool_active("ctx_architecture"));
403        state.load_category(ToolCategory::Arch);
404        assert!(state.is_tool_active("ctx_architecture"));
405    }
406
407    #[test]
408    fn cannot_unload_core() {
409        let mut state = DynamicToolState::new();
410        assert!(!state.unload_category(ToolCategory::Core));
411    }
412
413    #[test]
414    fn all_tools_visible_without_list_changed() {
415        let state = DynamicToolState::new();
416        assert!(state.is_tool_active("ctx_graph"));
417        assert!(!state.is_tool_active("ctx_metrics")); // Internal tools never active
418    }
419
420    #[test]
421    fn internal_tools_never_active() {
422        let state = DynamicToolState::all_enabled();
423        assert!(!state.is_tool_active("ctx_metrics"));
424        assert!(!state.is_tool_active("ctx_cost"));
425        assert!(!state.is_tool_active("ctx_discover_tools"));
426        assert!(!state.is_tool_active("ctx_dedup"));
427    }
428
429    // --- from_config: basic scenarios ---
430
431    #[test]
432    fn from_config_core_arch_memory() {
433        let cats = vec!["core".to_string(), "arch".to_string(), "memory".to_string()];
434        let mut state = DynamicToolState::from_config(&cats);
435        state.set_supports_list_changed(true);
436        assert!(state.is_tool_active("ctx_read"));
437        assert!(state.is_tool_active("ctx_architecture"));
438        assert!(state.is_tool_active("ctx_artifacts"));
439        assert!(!state.is_tool_active("ctx_benchmark"));
440        assert!(!state.is_tool_active("ctx_fill"));
441    }
442
443    #[test]
444    fn from_config_empty_still_has_core() {
445        let mut state = DynamicToolState::from_config(&[]);
446        state.set_supports_list_changed(true);
447        assert!(state.is_tool_active("ctx_read"));
448        assert!(!state.is_tool_active("ctx_architecture"));
449        assert!(!state.is_tool_active("ctx_benchmark"));
450        assert!(!state.is_tool_active("ctx_artifacts"));
451    }
452
453    // --- from_config: all categories ---
454
455    #[test]
456    fn from_config_all_categories_enables_everything_except_internal() {
457        let cats = vec![
458            "core".to_string(),
459            "arch".to_string(),
460            "debug".to_string(),
461            "memory".to_string(),
462            "metrics".to_string(),
463            "session".to_string(),
464        ];
465        let mut state = DynamicToolState::from_config(&cats);
466        state.set_supports_list_changed(true);
467        assert!(state.is_tool_active("ctx_read"));
468        assert!(state.is_tool_active("ctx_architecture"));
469        assert!(state.is_tool_active("ctx_benchmark"));
470        assert!(state.is_tool_active("ctx_semantic_search"));
471        assert!(state.is_tool_active("ctx_fill"));
472        assert!(state.is_tool_active("ctx_workflow"));
473        assert!(!state.is_tool_active("ctx_metrics"));
474    }
475
476    // --- from_config: single category ---
477
478    #[test]
479    fn from_config_only_debug() {
480        let cats = vec!["debug".to_string()];
481        let mut state = DynamicToolState::from_config(&cats);
482        state.set_supports_list_changed(true);
483        assert!(state.is_tool_active("ctx_read"));
484        assert!(state.is_tool_active("ctx_benchmark"));
485        assert!(!state.is_tool_active("ctx_architecture"));
486        assert!(!state.is_tool_active("ctx_workflow"));
487    }
488
489    // --- from_config: invalid categories are silently ignored ---
490
491    #[test]
492    fn from_config_ignores_unknown_categories() {
493        let cats = vec![
494            "core".to_string(),
495            "nonexistent".to_string(),
496            "foobar".to_string(),
497        ];
498        let mut state = DynamicToolState::from_config(&cats);
499        state.set_supports_list_changed(true);
500        assert!(state.is_tool_active("ctx_read"));
501        assert!(!state.is_tool_active("ctx_architecture"));
502    }
503
504    #[test]
505    fn from_config_only_invalid_still_has_core() {
506        let cats = vec!["invalid".to_string(), "bogus".to_string()];
507        let mut state = DynamicToolState::from_config(&cats);
508        state.set_supports_list_changed(true);
509        assert!(state.is_tool_active("ctx_read"));
510        assert!(!state.is_tool_active("ctx_benchmark"));
511    }
512
513    // --- from_config: duplicate categories are idempotent ---
514
515    #[test]
516    fn from_config_duplicates_are_harmless() {
517        let cats = vec!["arch".to_string(), "arch".to_string(), "arch".to_string()];
518        let mut state = DynamicToolState::from_config(&cats);
519        state.set_supports_list_changed(true);
520        assert!(state.is_tool_active("ctx_architecture"));
521        let active = state.active_categories();
522        let arch_count = active.iter().filter(|&&c| c == "arch").count();
523        assert_eq!(arch_count, 1);
524    }
525
526    // --- from_config: internal category is never user-activatable ---
527
528    #[test]
529    fn from_config_internal_category_not_parseable() {
530        assert!(ToolCategory::parse("internal").is_none());
531    }
532
533    // --- from_config: category aliases work ---
534
535    #[test]
536    fn from_config_alias_architecture_maps_to_arch() {
537        let cats = vec!["architecture".to_string()];
538        let mut state = DynamicToolState::from_config(&cats);
539        state.set_supports_list_changed(true);
540        assert!(state.is_tool_active("ctx_architecture"));
541    }
542
543    #[test]
544    fn from_config_alias_profiling_maps_to_debug() {
545        let cats = vec!["profiling".to_string()];
546        let mut state = DynamicToolState::from_config(&cats);
547        state.set_supports_list_changed(true);
548        assert!(state.is_tool_active("ctx_benchmark"));
549    }
550
551    #[test]
552    fn from_config_alias_semantic_maps_to_memory() {
553        let cats = vec!["semantic".to_string()];
554        let mut state = DynamicToolState::from_config(&cats);
555        state.set_supports_list_changed(true);
556        assert!(state.is_tool_active("ctx_artifacts"));
557    }
558
559    // --- from_config: subsequent load/unload still works ---
560
561    #[test]
562    fn from_config_then_load_additional_category() {
563        let cats = vec!["core".to_string()];
564        let mut state = DynamicToolState::from_config(&cats);
565        state.set_supports_list_changed(true);
566        assert!(!state.is_tool_active("ctx_architecture"));
567        state.load_category(ToolCategory::Arch);
568        assert!(state.is_tool_active("ctx_architecture"));
569    }
570
571    #[test]
572    fn from_config_then_unload_non_core_category() {
573        let cats = vec!["core".to_string(), "arch".to_string()];
574        let mut state = DynamicToolState::from_config(&cats);
575        state.set_supports_list_changed(true);
576        assert!(state.is_tool_active("ctx_architecture"));
577        state.unload_category(ToolCategory::Arch);
578        assert!(!state.is_tool_active("ctx_architecture"));
579    }
580
581    #[test]
582    fn from_config_cannot_unload_core() {
583        let cats = vec!["core".to_string(), "arch".to_string()];
584        let mut state = DynamicToolState::from_config(&cats);
585        assert!(!state.unload_category(ToolCategory::Core));
586    }
587
588    // --- from_config: without list_changed, all tools visible ---
589
590    #[test]
591    fn from_config_without_list_changed_shows_all() {
592        let cats = vec!["core".to_string()];
593        let state = DynamicToolState::from_config(&cats);
594        assert!(state.is_tool_active("ctx_architecture"));
595        assert!(state.is_tool_active("ctx_benchmark"));
596        assert!(!state.is_tool_active("ctx_metrics"));
597    }
598
599    #[test]
600    fn lazy_core_tools_survive_default_category_gate() {
601        // Regression for #575: every advertised lazy-core tool must stay
602        // active under the default category gate (Core + Session) when the
603        // client supports list_changed — otherwise Cursor silently loses
604        // tools like ctx_expand from the 13-tool core set.
605        let mut state = DynamicToolState::new();
606        state.set_supports_list_changed(true);
607        for name in crate::tool_defs::core_tool_names() {
608            assert!(
609                state.is_tool_active(name),
610                "{name} is in CORE_TOOL_NAMES but dropped by the default category gate"
611            );
612        }
613    }
614
615    #[test]
616    fn categorize_known_tools() {
617        assert_eq!(categorize_tool("ctx_read"), ToolCategory::Core);
618        assert_eq!(categorize_tool("ctx_graph"), ToolCategory::Core);
619        assert_eq!(categorize_tool("ctx_benchmark"), ToolCategory::Debug);
620        assert_eq!(categorize_tool("ctx_semantic_search"), ToolCategory::Core);
621        assert_eq!(categorize_tool("ctx_artifacts"), ToolCategory::Memory);
622        assert_eq!(categorize_tool("ctx_metrics"), ToolCategory::Internal);
623        assert_eq!(categorize_tool("ctx_workflow"), ToolCategory::Session);
624    }
625
626    #[test]
627    fn readonly_classification() {
628        assert!(is_readonly_tool("ctx_read"));
629        assert!(is_readonly_tool("ctx_search"));
630        assert!(is_readonly_tool("ctx_tree"));
631        assert!(is_readonly_tool("ctx_overview"));
632        assert!(is_readonly_tool("ctx_provider"));
633
634        assert!(!is_readonly_tool("ctx_edit"));
635        assert!(!is_readonly_tool("ctx_shell"));
636        assert!(!is_readonly_tool("ctx_compile"));
637        assert!(!is_readonly_tool("ctx_execute"));
638        assert!(!is_readonly_tool("ctx_cache"));
639    }
640
641    #[test]
642    fn plan_mode_tools_are_all_readonly() {
643        for tool in crate::core::editor_registry::plan_mode::plan_mode_tools() {
644            assert!(
645                is_readonly_tool(tool),
646                "{tool} is listed as plan mode tool but not marked readonly"
647            );
648        }
649    }
650}