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 sanitize_schema(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
18fn sanitize_schema(schema: Value) -> Value {
24 let Value::Object(mut schema) = schema else {
25 return schema;
26 };
27 schema.remove("oneOf");
28 if let Some(any_of) = schema.get("anyOf") {
31 let dominated_by_required = any_of.as_array().is_some_and(|arr| {
32 arr.iter().all(|branch| {
33 branch.is_object()
34 && branch.get("type").is_none()
35 && branch.get("required").is_some()
36 })
37 });
38 if dominated_by_required {
39 schema.remove("anyOf");
40 }
41 }
42 Value::Object(schema)
43}
44
45pub const READONLY_TOOL_NAMES: &[&str] = &[
53 "ctx_read",
54 "ctx_tree",
55 "ctx_glob",
56 "ctx_callgraph",
57 "ctx_overview",
58 "ctx_expand",
59 "ctx_explore",
60 "ctx_delta",
61 "ctx_url_read",
62 "ctx_benchmark",
63 "ctx_analyze",
64 "ctx_discover",
65 "ctx_response",
66];
67
68pub const DESTRUCTIVE_TOOL_NAMES: &[&str] = &["ctx_shell", "ctx_execute", "ctx_patch"];
70
71pub fn apply_tool_annotations(tools: Vec<Tool>) -> Vec<Tool> {
74 tools
75 .into_iter()
76 .map(|t| {
77 let name = t.name.as_ref();
78 if READONLY_TOOL_NAMES.contains(&name) {
79 t.annotate(
80 ToolAnnotations::new()
81 .read_only(true)
82 .destructive(false)
83 .idempotent(true),
84 )
85 } else if DESTRUCTIVE_TOOL_NAMES.contains(&name) {
86 t.annotate(ToolAnnotations::new().destructive(true))
87 } else {
88 t
89 }
90 })
91 .collect()
92}
93
94pub fn normalize_for_strict_validators(schema: &mut Map<String, Value>) {
112 let is_object = schema.get("type").and_then(Value::as_str) == Some("object");
113 let is_array = schema.get("type").and_then(Value::as_str) == Some("array");
114
115 if is_object && schema.contains_key("properties") && !schema.contains_key("required") {
116 schema.insert("required".into(), Value::Array(Vec::new()));
117 }
118 if is_array && !schema.contains_key("items") {
119 schema.insert("items".into(), Value::Object(Map::new()));
120 }
121
122 if let Some(Value::Object(props)) = schema.get_mut("properties") {
123 for prop in props.values_mut() {
124 if let Value::Object(p) = prop {
125 normalize_for_strict_validators(p);
126 }
127 }
128 }
129 if let Some(Value::Object(items)) = schema.get_mut("items") {
130 normalize_for_strict_validators(items);
131 }
132 if let Some(Value::Object(ap)) = schema.get_mut("additionalProperties") {
133 normalize_for_strict_validators(ap);
134 }
135 for combinator in ["anyOf", "oneOf", "allOf"] {
136 if let Some(Value::Array(branches)) = schema.get_mut(combinator) {
137 for branch in branches.iter_mut() {
138 if let Value::Object(b) = branch {
139 normalize_for_strict_validators(b);
140 }
141 }
142 }
143 }
144 for keyword in ["if", "then", "else", "not"] {
145 if let Some(Value::Object(sub)) = schema.get_mut(keyword) {
146 normalize_for_strict_validators(sub);
147 }
148 }
149}
150
151pub const CORE_TOOL_NAMES: &[&str] = &[
152 "ctx_read",
153 "ctx_shell",
154 "shell",
155 "ctx_search",
158 "ctx_glob",
159 "ctx_tree",
160 "ctx_session",
161 "ctx_compose",
162 "ctx_callgraph",
167 "ctx_patch",
174 "ctx_call",
175 "ctx_expand",
176];
177
178pub fn core_tool_names() -> &'static [&'static str] {
179 CORE_TOOL_NAMES
180}
181
182pub fn lazy_tool_defs() -> Vec<Tool> {
183 let all = granular_tool_defs();
184 all.into_iter()
185 .filter(|t| CORE_TOOL_NAMES.contains(&t.name.as_ref()))
186 .collect()
187}
188
189pub fn discover_tools(query: &str) -> String {
190 let all = crate::server::registry::build_registry().tool_defs();
193 let query_lower = query.to_lowercase();
194 let matches: Vec<(String, String)> = all
195 .iter()
196 .filter_map(|t| {
197 let name = t.name.as_ref();
198 let desc = t.description.as_deref().unwrap_or("");
199 if name.to_lowercase().contains(&query_lower)
200 || desc.to_lowercase().contains(&query_lower)
201 {
202 Some((name.to_string(), desc.to_string()))
203 } else {
204 None
205 }
206 })
207 .collect();
208
209 if matches.is_empty() {
210 return format!(
211 "No tools found matching '{query}'. Try broader terms like: graph, cost, session, search, compress, agent, workflow, gain."
212 );
213 }
214
215 let mut out = format!("{} tools matching '{query}':\n", matches.len());
216 for (name, desc) in &matches {
217 let first = desc.lines().next().unwrap_or(desc);
219 let short = if first.len() > 80 {
220 &first[..first.floor_char_boundary(80)]
221 } else {
222 first
223 };
224 out.push_str(&format!(" {name} — {short}\n"));
225 }
226 out.push_str(
227 "\nIf your MCP client registers tools only once at startup (static tools/list), \
228use ctx_call (available in lazy mode) to invoke discovered tools:\n\
229 ctx_call {\"name\":\"ctx_graph\",\"arguments\":{\"action\":\"status\"}}\n",
230 );
231 out
232}
233
234pub fn is_full_mode() -> bool {
235 std::env::var("LEAN_CTX_FULL_TOOLS").is_ok_and(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
236 || std::env::var("LEAN_CTX_LAZY_TOOLS")
237 .is_ok_and(|v| v == "0" || v.eq_ignore_ascii_case("false"))
238}
239
240#[cfg(test)]
241mod tests {
242 use serde_json::json;
243
244 use super::sanitize_schema;
245
246 #[test]
247 fn sanitize_schema_strips_one_of_preserves_all_of() {
248 let sanitized = sanitize_schema(json!({
249 "type": "object",
250 "properties": {"command": {"type": "string"}},
251 "required": ["base"],
252 "oneOf": [
253 {"required": ["command", "cwd"]},
254 {"required": ["command", "timeout"]}
255 ],
256 "allOf": [{"type": "object"}],
257 "anyOf": [{"type": "object"}]
258 }));
259
260 assert_eq!(
261 sanitized,
262 json!({
263 "type": "object",
264 "properties": {"command": {"type": "string"}},
265 "required": ["base"],
266 "allOf": [{"type": "object"}],
267 "anyOf": [{"type": "object"}]
268 })
269 );
270 }
271
272 #[test]
273 fn sanitize_schema_preserves_schema_without_one_of() {
274 let schema = json!({
275 "type": "object",
276 "properties": {"command": {"type": "string"}},
277 "required": ["command"]
278 });
279
280 assert_eq!(sanitize_schema(schema.clone()), schema);
281 }
282
283 #[test]
284 fn sanitize_strips_root_anyof_with_required_only_branches() {
285 let schema = json!({
286 "type": "object",
287 "properties": { "a": { "type": "string" } },
288 "anyOf": [
289 { "required": ["a"] },
290 { "required": ["b", "c"] }
291 ]
292 });
293
294 let result = sanitize_schema(schema);
295
296 assert!(result.get("anyOf").is_none());
297 assert!(result.get("properties").is_some());
298 }
299
300 #[test]
301 fn sanitize_preserves_anyof_with_typed_branches() {
302 let schema = json!({
303 "type": "object",
304 "anyOf": [
305 { "type": "object", "properties": { "a": { "type": "string" } } }
306 ]
307 });
308
309 let result = sanitize_schema(schema);
310
311 assert!(result.get("anyOf").is_some());
312 }
313}