Skip to main content

lean_ctx/tool_defs/
mod.rs

1use 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
18/// Tools that never mutate their environment (files, indexes, session state).
19/// MCP clients (Cursor, Claude Desktop) may use `readOnlyHint` to allow these
20/// tools in restricted/readonly subagent contexts.
21pub 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
39/// Tools that may destructively modify their environment.
40pub const DESTRUCTIVE_TOOL_NAMES: &[&str] = &["ctx_shell", "ctx_execute", "ctx_patch"];
41
42/// Apply MCP `ToolAnnotations` (readOnlyHint, destructiveHint) to a set of
43/// tool definitions. Called by the registry before serving `tools/list`.
44pub 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
65/// Make a tool input schema acceptable to *strict* JSON-Schema validators.
66///
67/// OpenAI/Azure (Pydantic-based), Claude thinking models and OpenAI-compatible
68/// backends like SGLang reject tool schemas that are valid JSON Schema but
69/// omit fields the spec treats as optional. Community-reported failures
70/// (OpenCode: "Invalid schema for function 'lean-ctx_ctx_expand': None is not
71/// of type 'array'"):
72///
73/// - `type: "object"` with `properties` but no `required` → clients forward
74///   `required: null` and the backend 400s. We always emit an explicit array.
75/// - `type: "array"` without `items` → "array schema missing items". We emit
76///   a permissive `items: {}` so the wire schema is self-contained.
77///
78/// Runs recursively over every nested schema position (`properties`, `items`,
79/// `anyOf`/`oneOf`/`allOf`, object-shaped `additionalProperties`) so nested
80/// definitions get the same guarantees. Existing `required` arrays are
81/// preserved verbatim — this never changes which parameters are mandatory.
82pub 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    // #509: ctx_search now subsumes semantic search + symbol lookup via `action`;
127    // ctx_semantic_search/ctx_symbol are deprecated aliases hidden from the surface.
128    "ctx_search",
129    "ctx_glob",
130    "ctx_tree",
131    "ctx_session",
132    "ctx_compose",
133    // #578: the injected INTENT playbook routes "callers/impact" to
134    // ctx_callgraph, so the advertised core matches the rules. ctx_graph
135    // (file-level deps, ~300 tok schema) stays reachable via ctx_call and the
136    // standard/power profiles.
137    "ctx_callgraph",
138    // #1008 anchored editing: the rules route "edit after reading" to ctx_patch,
139    // so the default surface must advertise it — but only where it earns its
140    // tokens. Clients with a reliable native str-replace editor (Cursor, Zed,
141    // Windsurf, …) skip it via the lazy-core client quirk in
142    // `server::tool_visibility::ClientQuirks`; Claude Code, SDK harnesses and
143    // unknown/headless clients get it.
144    "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    // Derived from the registry (single source of truth) so discovery results
162    // never drift from the advertised tool schemas (#141).
163    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        // First line only — registry descriptions can be multi-line.
189        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}