lean_ctx/tool_defs/
mod.rs1use std::sync::Arc;
2
3use rmcp::model::{Tool, ToolAnnotations};
4use serde_json::{Map, Value};
5
6mod granular;
7pub use granular::{granular_tool_defs, unified_tool_defs};
8
9pub fn tool_def(name: &'static str, description: &'static str, schema_value: Value) -> Tool {
10 let mut schema: Map<String, Value> = match schema_value {
11 Value::Object(map) => map,
12 _ => Map::new(),
13 };
14 normalize_for_strict_validators(&mut schema);
15 Tool::new(name, description, Arc::new(schema))
16}
17
18pub const READONLY_TOOL_NAMES: &[&str] = &[
22 "ctx_read",
23 "ctx_compose",
24 "ctx_search",
25 "ctx_tree",
26 "ctx_glob",
27 "ctx_callgraph",
28 "ctx_overview",
29 "ctx_expand",
30 "ctx_explore",
31 "ctx_delta",
32 "ctx_url_read",
33 "ctx_benchmark",
34 "ctx_analyze",
35 "ctx_discover",
36 "ctx_response",
37];
38
39pub const DESTRUCTIVE_TOOL_NAMES: &[&str] = &["ctx_shell", "ctx_execute", "ctx_patch"];
41
42pub fn apply_tool_annotations(tools: Vec<Tool>) -> Vec<Tool> {
45 tools
46 .into_iter()
47 .map(|t| {
48 let name = t.name.as_ref();
49 if READONLY_TOOL_NAMES.contains(&name) {
50 t.annotate(
51 ToolAnnotations::new()
52 .read_only(true)
53 .destructive(false)
54 .idempotent(true),
55 )
56 } else if DESTRUCTIVE_TOOL_NAMES.contains(&name) {
57 t.annotate(ToolAnnotations::new().destructive(true))
58 } else {
59 t
60 }
61 })
62 .collect()
63}
64
65pub fn normalize_for_strict_validators(schema: &mut Map<String, Value>) {
83 let is_object = schema.get("type").and_then(Value::as_str) == Some("object");
84 let is_array = schema.get("type").and_then(Value::as_str) == Some("array");
85
86 if is_object && schema.contains_key("properties") && !schema.contains_key("required") {
87 schema.insert("required".into(), Value::Array(Vec::new()));
88 }
89 if is_array && !schema.contains_key("items") {
90 schema.insert("items".into(), Value::Object(Map::new()));
91 }
92
93 if let Some(Value::Object(props)) = schema.get_mut("properties") {
94 for prop in props.values_mut() {
95 if let Value::Object(p) = prop {
96 normalize_for_strict_validators(p);
97 }
98 }
99 }
100 if let Some(Value::Object(items)) = schema.get_mut("items") {
101 normalize_for_strict_validators(items);
102 }
103 if let Some(Value::Object(ap)) = schema.get_mut("additionalProperties") {
104 normalize_for_strict_validators(ap);
105 }
106 for combinator in ["anyOf", "oneOf", "allOf"] {
107 if let Some(Value::Array(branches)) = schema.get_mut(combinator) {
108 for branch in branches.iter_mut() {
109 if let Value::Object(b) = branch {
110 normalize_for_strict_validators(b);
111 }
112 }
113 }
114 }
115 for keyword in ["if", "then", "else", "not"] {
116 if let Some(Value::Object(sub)) = schema.get_mut(keyword) {
117 normalize_for_strict_validators(sub);
118 }
119 }
120}
121
122pub const CORE_TOOL_NAMES: &[&str] = &[
123 "ctx_read",
124 "ctx_shell",
125 "shell",
126 "ctx_search",
129 "ctx_glob",
130 "ctx_tree",
131 "ctx_session",
132 "ctx_compose",
133 "ctx_callgraph",
138 "ctx_patch",
145 "ctx_call",
146 "ctx_expand",
147];
148
149pub fn core_tool_names() -> &'static [&'static str] {
150 CORE_TOOL_NAMES
151}
152
153pub fn lazy_tool_defs() -> Vec<Tool> {
154 let all = granular_tool_defs();
155 all.into_iter()
156 .filter(|t| CORE_TOOL_NAMES.contains(&t.name.as_ref()))
157 .collect()
158}
159
160pub fn discover_tools(query: &str) -> String {
161 let all = crate::server::registry::build_registry().tool_defs();
164 let query_lower = query.to_lowercase();
165 let matches: Vec<(String, String)> = all
166 .iter()
167 .filter_map(|t| {
168 let name = t.name.as_ref();
169 let desc = t.description.as_deref().unwrap_or("");
170 if name.to_lowercase().contains(&query_lower)
171 || desc.to_lowercase().contains(&query_lower)
172 {
173 Some((name.to_string(), desc.to_string()))
174 } else {
175 None
176 }
177 })
178 .collect();
179
180 if matches.is_empty() {
181 return format!(
182 "No tools found matching '{query}'. Try broader terms like: graph, cost, session, search, compress, agent, workflow, gain."
183 );
184 }
185
186 let mut out = format!("{} tools matching '{query}':\n", matches.len());
187 for (name, desc) in &matches {
188 let first = desc.lines().next().unwrap_or(desc);
190 let short = if first.len() > 80 {
191 &first[..first.floor_char_boundary(80)]
192 } else {
193 first
194 };
195 out.push_str(&format!(" {name} — {short}\n"));
196 }
197 out.push_str(
198 "\nIf your MCP client registers tools only once at startup (static tools/list), \
199use ctx_call (available in lazy mode) to invoke discovered tools:\n\
200 ctx_call {\"name\":\"ctx_graph\",\"arguments\":{\"action\":\"status\"}}\n",
201 );
202 out
203}
204
205pub fn is_full_mode() -> bool {
206 std::env::var("LEAN_CTX_FULL_TOOLS").is_ok_and(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
207 || std::env::var("LEAN_CTX_LAZY_TOOLS")
208 .is_ok_and(|v| v == "0" || v.eq_ignore_ascii_case("false"))
209}