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