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");
33 schema.remove("allOf");
34 schema.remove("anyOf");
35 schema.remove("if");
38 schema.remove("then");
39 schema.remove("else");
40 Value::Object(schema)
41}
42
43pub const READONLY_TOOL_NAMES: &[&str] = &[
51 "ctx_read",
52 "ctx_tree",
53 "ctx_glob",
54 "ctx_callgraph",
55 "ctx_overview",
56 "ctx_expand",
57 "ctx_explore",
58 "ctx_delta",
59 "ctx_url_read",
60 "ctx_benchmark",
61 "ctx_analyze",
62 "ctx_discover",
63 "ctx_response",
64];
65
66pub const DESTRUCTIVE_TOOL_NAMES: &[&str] = &["ctx_shell", "ctx_execute", "ctx_patch"];
68
69pub fn apply_tool_annotations(tools: Vec<Tool>) -> Vec<Tool> {
72 tools
73 .into_iter()
74 .map(|t| {
75 let name = t.name.as_ref();
76 if READONLY_TOOL_NAMES.contains(&name) {
77 t.annotate(
78 ToolAnnotations::new()
79 .read_only(true)
80 .destructive(false)
81 .idempotent(true),
82 )
83 } else if DESTRUCTIVE_TOOL_NAMES.contains(&name) {
84 t.annotate(ToolAnnotations::new().destructive(true))
85 } else {
86 t
87 }
88 })
89 .collect()
90}
91
92pub fn normalize_for_strict_validators(schema: &mut Map<String, Value>) {
110 let is_object = schema.get("type").and_then(Value::as_str) == Some("object");
111 let is_array = schema.get("type").and_then(Value::as_str) == Some("array");
112
113 if is_object && schema.contains_key("properties") && !schema.contains_key("required") {
114 schema.insert("required".into(), Value::Array(Vec::new()));
115 }
116 if is_array && !schema.contains_key("items") {
117 schema.insert("items".into(), Value::Object(Map::new()));
118 }
119
120 if let Some(Value::Object(props)) = schema.get_mut("properties") {
121 for prop in props.values_mut() {
122 if let Value::Object(p) = prop {
123 normalize_for_strict_validators(p);
124 }
125 }
126 }
127 if let Some(Value::Object(items)) = schema.get_mut("items") {
128 normalize_for_strict_validators(items);
129 }
130 if let Some(Value::Object(ap)) = schema.get_mut("additionalProperties") {
131 normalize_for_strict_validators(ap);
132 }
133 for combinator in ["anyOf", "oneOf", "allOf"] {
134 if let Some(Value::Array(branches)) = schema.get_mut(combinator) {
135 for branch in branches.iter_mut() {
136 if let Value::Object(b) = branch {
137 normalize_for_strict_validators(b);
138 }
139 }
140 }
141 }
142 for keyword in ["if", "then", "else", "not"] {
143 if let Some(Value::Object(sub)) = schema.get_mut(keyword) {
144 normalize_for_strict_validators(sub);
145 }
146 }
147}
148
149pub const CORE_TOOL_NAMES: &[&str] = &[
150 "ctx_read",
151 "ctx_shell",
152 "shell",
153 "ctx_search",
156 "ctx_glob",
157 "ctx_tree",
158 "ctx_session",
159 "ctx_compose",
160 "ctx_callgraph",
165 "ctx_patch",
172 "ctx_call",
173 "ctx_expand",
174];
175
176pub fn core_tool_names() -> &'static [&'static str] {
177 CORE_TOOL_NAMES
178}
179
180pub fn lazy_tool_defs() -> Vec<Tool> {
181 let all = granular_tool_defs();
182 all.into_iter()
183 .filter(|t| CORE_TOOL_NAMES.contains(&t.name.as_ref()))
184 .collect()
185}
186
187pub fn discover_tools(query: &str) -> String {
188 let all = crate::server::registry::build_registry().tool_defs();
191 let query_lower = query.to_lowercase();
192 let matches: Vec<(String, String)> = all
193 .iter()
194 .filter_map(|t| {
195 let name = t.name.as_ref();
196 let desc = t.description.as_deref().unwrap_or("");
197 if name.to_lowercase().contains(&query_lower)
198 || desc.to_lowercase().contains(&query_lower)
199 {
200 Some((name.to_string(), desc.to_string()))
201 } else {
202 None
203 }
204 })
205 .collect();
206
207 if matches.is_empty() {
208 return format!(
209 "No tools found matching '{query}'. Try broader terms like: graph, cost, session, search, compress, agent, workflow, gain."
210 );
211 }
212
213 let mut out = format!("{} tools matching '{query}':\n", matches.len());
214 for (name, desc) in &matches {
215 let first = desc.lines().next().unwrap_or(desc);
217 let short = if first.len() > 80 {
218 &first[..first.floor_char_boundary(80)]
219 } else {
220 first
221 };
222 out.push_str(&format!(" {name} — {short}\n"));
223 }
224 out.push_str(
225 "\nIf your MCP client registers tools only once at startup (static tools/list), \
226use ctx_call (available in lazy mode) to invoke discovered tools:\n\
227 ctx_call {\"name\":\"ctx_graph\",\"arguments\":{\"action\":\"status\"}}\n",
228 );
229 out
230}
231
232pub fn is_full_mode() -> bool {
233 std::env::var("LEAN_CTX_FULL_TOOLS").is_ok_and(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
234 || std::env::var("LEAN_CTX_LAZY_TOOLS")
235 .is_ok_and(|v| v == "0" || v.eq_ignore_ascii_case("false"))
236}
237
238#[cfg(test)]
239mod tests {
240 use serde_json::json;
241
242 use super::sanitize_schema;
243
244 #[test]
245 fn sanitize_schema_strips_all_combinators() {
246 let sanitized = sanitize_schema(json!({
247 "type": "object",
248 "properties": {"command": {"type": "string"}},
249 "required": ["base"],
250 "oneOf": [
251 {"required": ["command", "cwd"]},
252 {"required": ["command", "timeout"]}
253 ],
254 "allOf": [
255 {"if": {"properties": {"action": {"const": "x"}}}, "then": {"required": ["y"]}}
256 ],
257 "anyOf": [{"type": "object"}],
258 "if": {"properties": {"action": {"const": "z"}}},
259 "then": {"required": ["w"]}
260 }));
261
262 assert_eq!(
263 sanitized,
264 json!({
265 "type": "object",
266 "properties": {"command": {"type": "string"}},
267 "required": ["base"]
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_strips_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_none());
312 }
313}