Skip to main content

lean_ctx/tool_defs/
mod.rs

1use std::sync::Arc;
2
3use rmcp::model::Tool;
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
18/// Make a tool input schema acceptable to *strict* JSON-Schema validators.
19///
20/// OpenAI/Azure (Pydantic-based), Claude thinking models and OpenAI-compatible
21/// backends like SGLang reject tool schemas that are valid JSON Schema but
22/// omit fields the spec treats as optional. Community-reported failures
23/// (OpenCode: "Invalid schema for function 'lean-ctx_ctx_expand': None is not
24/// of type 'array'"):
25///
26/// - `type: "object"` with `properties` but no `required` → clients forward
27///   `required: null` and the backend 400s. We always emit an explicit array.
28/// - `type: "array"` without `items` → "array schema missing items". We emit
29///   a permissive `items: {}` so the wire schema is self-contained.
30///
31/// Runs recursively over every nested schema position (`properties`, `items`,
32/// `anyOf`/`oneOf`/`allOf`, object-shaped `additionalProperties`) so nested
33/// definitions get the same guarantees. Existing `required` arrays are
34/// preserved verbatim — this never changes which parameters are mandatory.
35pub fn normalize_for_strict_validators(schema: &mut Map<String, Value>) {
36    let is_object = schema.get("type").and_then(Value::as_str) == Some("object");
37    let is_array = schema.get("type").and_then(Value::as_str) == Some("array");
38
39    if is_object && schema.contains_key("properties") && !schema.contains_key("required") {
40        schema.insert("required".into(), Value::Array(Vec::new()));
41    }
42    if is_array && !schema.contains_key("items") {
43        schema.insert("items".into(), Value::Object(Map::new()));
44    }
45
46    if let Some(Value::Object(props)) = schema.get_mut("properties") {
47        for prop in props.values_mut() {
48            if let Value::Object(p) = prop {
49                normalize_for_strict_validators(p);
50            }
51        }
52    }
53    if let Some(Value::Object(items)) = schema.get_mut("items") {
54        normalize_for_strict_validators(items);
55    }
56    if let Some(Value::Object(ap)) = schema.get_mut("additionalProperties") {
57        normalize_for_strict_validators(ap);
58    }
59    for combinator in ["anyOf", "oneOf", "allOf"] {
60        if let Some(Value::Array(branches)) = schema.get_mut(combinator) {
61            for branch in branches.iter_mut() {
62                if let Value::Object(b) = branch {
63                    normalize_for_strict_validators(b);
64                }
65            }
66        }
67    }
68}
69
70pub const CORE_TOOL_NAMES: &[&str] = &[
71    "ctx_read",
72    "ctx_search",
73    "ctx_shell",
74    "shell",
75    "ctx_tree",
76    "ctx_edit",
77    "ctx_session",
78    "ctx_knowledge",
79    "ctx_overview",
80    "ctx_graph",
81    "ctx_call",
82    "ctx_provider",
83    "ctx_expand",
84];
85
86pub fn core_tool_names() -> &'static [&'static str] {
87    CORE_TOOL_NAMES
88}
89
90pub fn lazy_tool_defs() -> Vec<Tool> {
91    let all = granular_tool_defs();
92    all.into_iter()
93        .filter(|t| CORE_TOOL_NAMES.contains(&t.name.as_ref()))
94        .collect()
95}
96
97pub fn discover_tools(query: &str) -> String {
98    // Derived from the registry (single source of truth) so discovery results
99    // never drift from the advertised tool schemas (#141).
100    let all = crate::server::registry::build_registry().tool_defs();
101    let query_lower = query.to_lowercase();
102    let matches: Vec<(String, String)> = all
103        .iter()
104        .filter_map(|t| {
105            let name = t.name.as_ref();
106            let desc = t.description.as_deref().unwrap_or("");
107            if name.to_lowercase().contains(&query_lower)
108                || desc.to_lowercase().contains(&query_lower)
109            {
110                Some((name.to_string(), desc.to_string()))
111            } else {
112                None
113            }
114        })
115        .collect();
116
117    if matches.is_empty() {
118        return format!("No tools found matching '{query}'. Try broader terms like: graph, cost, session, search, compress, agent, workflow, gain.");
119    }
120
121    let mut out = format!("{} tools matching '{query}':\n", matches.len());
122    for (name, desc) in &matches {
123        // First line only — registry descriptions can be multi-line.
124        let first = desc.lines().next().unwrap_or(desc);
125        let short = if first.len() > 80 {
126            &first[..first.floor_char_boundary(80)]
127        } else {
128            first
129        };
130        out.push_str(&format!("  {name} — {short}\n"));
131    }
132    out.push_str(
133        "\nIf your MCP client registers tools only once at startup (static tools/list), \
134use ctx_call (available in lazy mode) to invoke discovered tools:\n\
135  ctx_call {\"name\":\"ctx_graph\",\"arguments\":{\"action\":\"status\"}}\n",
136    );
137    out
138}
139
140pub fn is_full_mode() -> bool {
141    std::env::var("LEAN_CTX_FULL_TOOLS").is_ok_and(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
142        || std::env::var("LEAN_CTX_LAZY_TOOLS")
143            .is_ok_and(|v| v == "0" || v.eq_ignore_ascii_case("false"))
144}