Skip to main content

lean_ctx_server/tools/registered/
ctx_load_tools.rs

1// SPDX-License-Identifier: Apache-2.0
2use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::dynamic_tools::{self, DynamicToolState, ToolCategory};
7use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput};
8use lean_ctx::tool_defs::tool_def;
9
10pub struct CtxLoadToolsTool;
11
12impl McpTool for CtxLoadToolsTool {
13    fn name(&self) -> &'static str {
14        "ctx_load_tools"
15    }
16
17    fn tool_def(&self) -> Tool {
18        tool_def(
19            "ctx_load_tools",
20            "Load/unload specialized tool categories to reduce surface area.\n\
21             WORKFLOW: list → load → unload when done.\n\
22             ANTI-PATTERN: don't unload categories you're actively using.\n\
23             Actions: load|unload|list. Categories: arch, debug, memory, metrics, session.\n\
24             Core is always loaded.",
25            json!({
26                "type": "object",
27                "properties": {
28                    "action": {
29                        "type": "string",
30                        "enum": ["load", "unload", "list"],
31                        "description": "load|unload|list"
32                    },
33                    "category": {
34                        "type": "string",
35                        "description": "arch|debug|memory|metrics|session"
36                    }
37                },
38                "allOf": [
39                    {
40                        "if": {
41                            "properties": { "action": { "const": "load" } },
42                            "required": ["action"]
43                        },
44                        "then": { "required": ["action", "category"] }
45                    },
46                    {
47                        "if": {
48                            "properties": { "action": { "const": "unload" } },
49                            "required": ["action"]
50                        },
51                        "then": { "required": ["action", "category"] }
52                    }
53                ],
54                "required": ["action"]
55            }),
56        )
57    }
58
59    fn handle(
60        &self,
61        args: &Map<String, Value>,
62        _ctx: &ToolContext,
63    ) -> Result<ToolOutput, ErrorData> {
64        let action = args
65            .get("action")
66            .and_then(|v| v.as_str())
67            .unwrap_or("list");
68        let category_str = args.get("category").and_then(|v| v.as_str());
69
70        match action {
71            "list" => Ok(ToolOutput::simple(format_category_status())),
72            "load" => {
73                let cat_name = category_str.ok_or_else(|| {
74                    ErrorData::invalid_params("'category' required for load action", None)
75                })?;
76                let cat = ToolCategory::parse(cat_name).ok_or_else(|| {
77                    ErrorData::invalid_params(
78                        format!(
79                            "Unknown category '{cat_name}'. Available: {}",
80                            DynamicToolState::all_categories().join(", ")
81                        ),
82                        None,
83                    )
84                })?;
85                let changed = {
86                    let Ok(mut state) = dynamic_tools::global().lock() else {
87                        return Err(ErrorData::internal_error("dynamic_tools lock failed", None));
88                    };
89                    state.load_category(cat)
90                };
91                let text = if changed {
92                    format!(
93                        "Loaded category '{cat_name}'.\n{}",
94                        format_category_status()
95                    )
96                } else {
97                    format!("Category '{cat_name}' was already loaded.")
98                };
99                let mut out = ToolOutput::simple(text);
100                out.changed = changed;
101                Ok(out)
102            }
103            "unload" => {
104                let cat_name = category_str.ok_or_else(|| {
105                    ErrorData::invalid_params("'category' required for unload action", None)
106                })?;
107                let cat = ToolCategory::parse(cat_name).ok_or_else(|| {
108                    ErrorData::invalid_params(
109                        format!(
110                            "Unknown category '{cat_name}'. Available: {}",
111                            DynamicToolState::all_categories().join(", ")
112                        ),
113                        None,
114                    )
115                })?;
116                let changed = {
117                    let Ok(mut state) = dynamic_tools::global().lock() else {
118                        return Err(ErrorData::internal_error("dynamic_tools lock failed", None));
119                    };
120                    state.unload_category(cat)
121                };
122                let text = if changed {
123                    format!(
124                        "Unloaded category '{cat_name}'.\n{}",
125                        format_category_status()
126                    )
127                } else if cat == ToolCategory::Core {
128                    "Cannot unload 'core' category.".to_string()
129                } else {
130                    format!("Category '{cat_name}' was not loaded.")
131                };
132                let mut out = ToolOutput::simple(text);
133                out.changed = changed;
134                Ok(out)
135            }
136            other => Err(ErrorData::invalid_params(
137                format!("Unknown action '{other}'. Use load|unload|list."),
138                None,
139            )),
140        }
141    }
142}
143
144fn format_category_status() -> String {
145    let Ok(state) = dynamic_tools::global().lock() else {
146        return "dynamic_tools: lock unavailable".to_string();
147    };
148    let active = state.active_categories();
149    let all = DynamicToolState::all_categories();
150    let mut lines = vec![format!(
151        "Dynamic tools: {} (list_changed={})",
152        if state.supports_list_changed() {
153            "active"
154        } else {
155            "all-visible"
156        },
157        state.supports_list_changed()
158    )];
159    for cat in &all {
160        let status = if active.contains(cat) {
161            "loaded"
162        } else {
163            "unloaded"
164        };
165        lines.push(format!("  {cat}: {status}"));
166    }
167    lines.join("\n")
168}