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 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
18/// Strip root union forms rejected by strict MCP schema validators.
19///
20/// The combinator is removed but no required fields are added — the published
21/// schema becomes more permissive and the handler does runtime validation.
22/// `allOf` and conditional `anyOf` forms are preserved.
23fn sanitize_schema(schema: Value) -> Value {
24    let Value::Object(mut schema) = schema else {
25        return schema;
26    };
27    schema.remove("oneOf");
28    // Grok and Gemini reject root union branches that are bare `required`
29    // subschemas, as emitted by the former ctx_shell schema (#1397).
30    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
45/// Tools that never mutate their environment (files, indexes, session state).
46/// MCP clients (Cursor, Claude Desktop) may use `readOnlyHint` to allow these
47/// tools in restricted/readonly subagent contexts.
48///
49/// Excluded from this list (despite mostly-read paths):
50/// - `ctx_compose` — calls `record_access()` (co-access / session state mutation)
51/// - `ctx_search` — `action=reindex` rebuilds persistent BM25 indexes
52pub 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
68/// Tools that may destructively modify their environment.
69pub const DESTRUCTIVE_TOOL_NAMES: &[&str] = &["ctx_shell", "ctx_execute", "ctx_patch"];
70
71/// Apply MCP `ToolAnnotations` (readOnlyHint, destructiveHint) to a set of
72/// tool definitions. Called by the registry before serving `tools/list`.
73pub 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
94/// Make a tool input schema acceptable to *strict* JSON-Schema validators.
95///
96/// OpenAI/Azure (Pydantic-based), Claude thinking models and OpenAI-compatible
97/// backends like SGLang reject tool schemas that are valid JSON Schema but
98/// omit fields the spec treats as optional. Community-reported failures
99/// (OpenCode: "Invalid schema for function 'lean-ctx_ctx_expand': None is not
100/// of type 'array'"):
101///
102/// - `type: "object"` with `properties` but no `required` → clients forward
103///   `required: null` and the backend 400s. We always emit an explicit array.
104/// - `type: "array"` without `items` → "array schema missing items". We emit
105///   a permissive `items: {}` so the wire schema is self-contained.
106///
107/// Runs recursively over every nested schema position (`properties`, `items`,
108/// `anyOf`/`oneOf`/`allOf`, object-shaped `additionalProperties`) so nested
109/// definitions get the same guarantees. Existing `required` arrays are
110/// preserved verbatim — this never changes which parameters are mandatory.
111pub 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    // #509: ctx_search now subsumes semantic search + symbol lookup via `action`;
156    // ctx_semantic_search/ctx_symbol are deprecated aliases hidden from the surface.
157    "ctx_search",
158    "ctx_glob",
159    "ctx_tree",
160    "ctx_session",
161    "ctx_compose",
162    // #578: the injected INTENT playbook routes "callers/impact" to
163    // ctx_callgraph, so the advertised core matches the rules. ctx_graph
164    // (file-level deps, ~300 tok schema) stays reachable via ctx_call and the
165    // standard/power profiles.
166    "ctx_callgraph",
167    // #1008 anchored editing: the rules route "edit after reading" to ctx_patch,
168    // so the default surface must advertise it — but only where it earns its
169    // tokens. Clients with a reliable native str-replace editor (Cursor, Zed,
170    // Windsurf, …) skip it via the lazy-core client quirk in
171    // `server::tool_visibility::ClientQuirks`; Claude Code, SDK harnesses and
172    // unknown/headless clients get it.
173    "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    // Derived from the registry (single source of truth) so discovery results
191    // never drift from the advertised tool schemas (#141).
192    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        // First line only — registry descriptions can be multi-line.
218        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}