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    // Anthropic, OpenCode, Grok, Gemini reject top-level oneOf/allOf/anyOf.
28    // All conditional validation (if/then, variant-specific required) is
29    // enforced at runtime by each tool's handle() method, so stripping
30    // these combinators makes the schema more permissive but functionally
31    // correct. (#1346)
32    schema.remove("oneOf");
33    schema.remove("allOf");
34    schema.remove("anyOf");
35    // Also strip if/then/else — they are only meaningful inside allOf
36    // branches and become dead weight once allOf is removed.
37    schema.remove("if");
38    schema.remove("then");
39    schema.remove("else");
40    Value::Object(schema)
41}
42
43/// Tools that never mutate their environment (files, indexes, session state).
44/// MCP clients (Cursor, Claude Desktop) may use `readOnlyHint` to allow these
45/// tools in restricted/readonly subagent contexts.
46///
47/// Excluded from this list (despite mostly-read paths):
48/// - `ctx_compose` — calls `record_access()` (co-access / session state mutation)
49pub const READONLY_TOOL_NAMES: &[&str] = &[
50    "ctx_read",
51    "ctx_tree",
52    "ctx_glob",
53    // #1624: `ctx_search` belongs here. Its one mutating action, `reindex`,
54    // moved to `ctx_index` — annotations are per tool, so that single action
55    // was withholding the hint from every search call and locking the tool out
56    // of read-only client modes (Devin Plan mode, Cursor's restricted context).
57    "ctx_search",
58    "ctx_callgraph",
59    "ctx_expand",
60    "ctx_delta",
61    "ctx_url_read",
62    "ctx_discover",
63];
64
65/// Tools that may destructively modify their environment.
66pub const DESTRUCTIVE_TOOL_NAMES: &[&str] = &["ctx_shell", "ctx_execute", "ctx_patch"];
67
68/// Apply MCP `ToolAnnotations` (readOnlyHint, destructiveHint) to a set of
69/// tool definitions. Called by the registry before serving `tools/list`.
70pub fn apply_tool_annotations(tools: Vec<Tool>) -> Vec<Tool> {
71    tools
72        .into_iter()
73        .map(|t| {
74            let name = t.name.as_ref();
75            if READONLY_TOOL_NAMES.contains(&name) {
76                t.annotate(
77                    ToolAnnotations::new()
78                        .read_only(true)
79                        .destructive(false)
80                        .idempotent(true),
81                )
82            } else if DESTRUCTIVE_TOOL_NAMES.contains(&name) {
83                t.annotate(ToolAnnotations::new().read_only(false).destructive(true))
84            } else {
85                t
86            }
87        })
88        .collect()
89}
90
91/// Make a tool input schema acceptable to *strict* JSON-Schema validators.
92///
93/// OpenAI/Azure (Pydantic-based), Claude thinking models and OpenAI-compatible
94/// backends like SGLang reject tool schemas that are valid JSON Schema but
95/// omit fields the spec treats as optional. Community-reported failures
96/// (OpenCode: "Invalid schema for function 'lean-ctx_ctx_expand': None is not
97/// of type 'array'"):
98///
99/// - `type: "object"` with `properties` but no `required` → clients forward
100///   `required: null` and the backend 400s. We always emit an explicit array.
101/// - `type: "array"` without `items` → "array schema missing items". We emit
102///   a permissive `items: {}` so the wire schema is self-contained.
103///
104/// Runs recursively over every nested schema position (`properties`, `items`,
105/// `anyOf`/`oneOf`/`allOf`, object-shaped `additionalProperties`) so nested
106/// definitions get the same guarantees. Existing `required` arrays are
107/// preserved verbatim — this never changes which parameters are mandatory.
108pub fn normalize_for_strict_validators(schema: &mut Map<String, Value>) {
109    let is_object = schema.get("type").and_then(Value::as_str) == Some("object");
110    let is_array = schema.get("type").and_then(Value::as_str) == Some("array");
111
112    if is_object && schema.contains_key("properties") && !schema.contains_key("required") {
113        schema.insert("required".into(), Value::Array(Vec::new()));
114    }
115    if is_array && !schema.contains_key("items") {
116        schema.insert("items".into(), Value::Object(Map::new()));
117    }
118
119    if let Some(Value::Object(props)) = schema.get_mut("properties") {
120        for prop in props.values_mut() {
121            if let Value::Object(p) = prop {
122                normalize_for_strict_validators(p);
123            }
124        }
125    }
126    if let Some(Value::Object(items)) = schema.get_mut("items") {
127        normalize_for_strict_validators(items);
128    }
129    if let Some(Value::Object(ap)) = schema.get_mut("additionalProperties") {
130        normalize_for_strict_validators(ap);
131    }
132    for combinator in ["anyOf", "oneOf", "allOf"] {
133        if let Some(Value::Array(branches)) = schema.get_mut(combinator) {
134            for branch in branches.iter_mut() {
135                if let Value::Object(b) = branch {
136                    normalize_for_strict_validators(b);
137                }
138            }
139        }
140    }
141    for keyword in ["if", "then", "else", "not"] {
142        if let Some(Value::Object(sub)) = schema.get_mut(keyword) {
143            normalize_for_strict_validators(sub);
144        }
145    }
146}
147
148pub const CORE_TOOL_NAMES: &[&str] = &[
149    "ctx_read",
150    "ctx_shell",
151    "shell",
152    // #509: ctx_search now subsumes semantic search + symbol lookup via `action`;
153    // ctx_semantic_search/ctx_symbol are deprecated aliases hidden from the surface.
154    "ctx_search",
155    "ctx_glob",
156    "ctx_tree",
157    "ctx_session",
158    "ctx_compose",
159    // #578: the injected INTENT playbook routes "callers/impact" to
160    // ctx_callgraph, so the advertised core matches the rules. ctx_graph
161    // (file-level deps, ~300 tok schema) stays reachable via ctx_call and the
162    // standard/power profiles.
163    "ctx_callgraph",
164    // #1008 anchored editing: the rules route "edit after reading" to ctx_patch,
165    // so the default surface must advertise it — but only where it earns its
166    // tokens. Clients with a reliable native str-replace editor (Cursor, Zed,
167    // Windsurf, …) skip it via the lazy-core client quirk in
168    // `core::tool_visibility::ClientQuirks`; Claude Code, SDK harnesses and
169    // unknown/headless clients get it.
170    "ctx_patch",
171    "ctx_call",
172    "ctx_expand",
173];
174
175pub fn core_tool_names() -> &'static [&'static str] {
176    CORE_TOOL_NAMES
177}
178
179pub fn lazy_tool_defs() -> Vec<Tool> {
180    let all = granular_tool_defs();
181    all.into_iter()
182        .filter(|t| CORE_TOOL_NAMES.contains(&t.name.as_ref()))
183        .collect()
184}
185
186pub fn discover_tools(query: &str) -> String {
187    // Derived from the registry (single source of truth) so discovery results
188    // never drift from the advertised tool schemas (#141).
189    let all = crate::core::tool_catalog::tool_defs();
190    let query_lower = query.to_lowercase();
191    let matches: Vec<(String, String)> = all
192        .iter()
193        .filter_map(|t| {
194            let name = t.name.as_ref();
195            let desc = t.description.as_deref().unwrap_or("");
196            if name.to_lowercase().contains(&query_lower)
197                || desc.to_lowercase().contains(&query_lower)
198            {
199                Some((name.to_string(), desc.to_string()))
200            } else {
201                None
202            }
203        })
204        .collect();
205
206    if matches.is_empty() {
207        return format!(
208            "No tools found matching '{query}'. Try broader terms like: graph, cost, session, search, compress, agent, workflow, gain."
209        );
210    }
211
212    let mut out = format!("{} tools matching '{query}':\n", matches.len());
213    for (name, desc) in &matches {
214        // First line only — registry descriptions can be multi-line.
215        let first = desc.lines().next().unwrap_or(desc);
216        let short = if first.len() > 80 {
217            &first[..first.floor_char_boundary(80)]
218        } else {
219            first
220        };
221        out.push_str(&format!("  {name} — {short}\n"));
222    }
223    out.push_str(
224        "\nIf your MCP client registers tools only once at startup (static tools/list), \
225use ctx_call (available in lazy mode) to invoke discovered tools:\n\
226  ctx_call {\"name\":\"ctx_graph\",\"arguments\":{\"action\":\"status\"}}\n",
227    );
228    out
229}
230
231pub fn is_full_mode() -> bool {
232    std::env::var("LEAN_CTX_FULL_TOOLS").is_ok_and(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
233        || std::env::var("LEAN_CTX_LAZY_TOOLS")
234            .is_ok_and(|v| v == "0" || v.eq_ignore_ascii_case("false"))
235}
236
237#[cfg(test)]
238mod tests {
239    use serde_json::json;
240
241    use super::sanitize_schema;
242
243    #[test]
244    fn sanitize_schema_strips_all_combinators() {
245        let sanitized = sanitize_schema(json!({
246            "type": "object",
247            "properties": {"command": {"type": "string"}},
248            "required": ["base"],
249            "oneOf": [
250                {"required": ["command", "cwd"]},
251                {"required": ["command", "timeout"]}
252            ],
253            "allOf": [
254                {"if": {"properties": {"action": {"const": "x"}}}, "then": {"required": ["y"]}}
255            ],
256            "anyOf": [{"type": "object"}],
257            "if": {"properties": {"action": {"const": "z"}}},
258            "then": {"required": ["w"]}
259        }));
260
261        assert_eq!(
262            sanitized,
263            json!({
264                "type": "object",
265                "properties": {"command": {"type": "string"}},
266                "required": ["base"]
267            })
268        );
269    }
270
271    #[test]
272    fn sanitize_schema_preserves_schema_without_one_of() {
273        let schema = json!({
274            "type": "object",
275            "properties": {"command": {"type": "string"}},
276            "required": ["command"]
277        });
278
279        assert_eq!(sanitize_schema(schema.clone()), schema);
280    }
281
282    #[test]
283    fn sanitize_strips_root_anyof_with_required_only_branches() {
284        let schema = json!({
285            "type": "object",
286            "properties": { "a": { "type": "string" } },
287            "anyOf": [
288                { "required": ["a"] },
289                { "required": ["b", "c"] }
290            ]
291        });
292
293        let result = sanitize_schema(schema);
294
295        assert!(result.get("anyOf").is_none());
296        assert!(result.get("properties").is_some());
297    }
298
299    #[test]
300    fn sanitize_strips_anyof_with_typed_branches() {
301        let schema = json!({
302            "type": "object",
303            "anyOf": [
304                { "type": "object", "properties": { "a": { "type": "string" } } }
305            ]
306        });
307
308        let result = sanitize_schema(schema);
309
310        assert!(result.get("anyOf").is_none());
311    }
312}