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)
49/// - `ctx_search` — `action=reindex` rebuilds persistent BM25 indexes
50pub 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
66/// Tools that may destructively modify their environment.
67pub const DESTRUCTIVE_TOOL_NAMES: &[&str] = &["ctx_shell", "ctx_execute", "ctx_patch"];
68
69/// Apply MCP `ToolAnnotations` (readOnlyHint, destructiveHint) to a set of
70/// tool definitions. Called by the registry before serving `tools/list`.
71pub 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
92/// Make a tool input schema acceptable to *strict* JSON-Schema validators.
93///
94/// OpenAI/Azure (Pydantic-based), Claude thinking models and OpenAI-compatible
95/// backends like SGLang reject tool schemas that are valid JSON Schema but
96/// omit fields the spec treats as optional. Community-reported failures
97/// (OpenCode: "Invalid schema for function 'lean-ctx_ctx_expand': None is not
98/// of type 'array'"):
99///
100/// - `type: "object"` with `properties` but no `required` → clients forward
101///   `required: null` and the backend 400s. We always emit an explicit array.
102/// - `type: "array"` without `items` → "array schema missing items". We emit
103///   a permissive `items: {}` so the wire schema is self-contained.
104///
105/// Runs recursively over every nested schema position (`properties`, `items`,
106/// `anyOf`/`oneOf`/`allOf`, object-shaped `additionalProperties`) so nested
107/// definitions get the same guarantees. Existing `required` arrays are
108/// preserved verbatim — this never changes which parameters are mandatory.
109pub 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    // #509: ctx_search now subsumes semantic search + symbol lookup via `action`;
154    // ctx_semantic_search/ctx_symbol are deprecated aliases hidden from the surface.
155    "ctx_search",
156    "ctx_glob",
157    "ctx_tree",
158    "ctx_session",
159    "ctx_compose",
160    // #578: the injected INTENT playbook routes "callers/impact" to
161    // ctx_callgraph, so the advertised core matches the rules. ctx_graph
162    // (file-level deps, ~300 tok schema) stays reachable via ctx_call and the
163    // standard/power profiles.
164    "ctx_callgraph",
165    // #1008 anchored editing: the rules route "edit after reading" to ctx_patch,
166    // so the default surface must advertise it — but only where it earns its
167    // tokens. Clients with a reliable native str-replace editor (Cursor, Zed,
168    // Windsurf, …) skip it via the lazy-core client quirk in
169    // `server::tool_visibility::ClientQuirks`; Claude Code, SDK harnesses and
170    // unknown/headless clients get it.
171    "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    // Derived from the registry (single source of truth) so discovery results
189    // never drift from the advertised tool schemas (#141).
190    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        // First line only — registry descriptions can be multi-line.
216        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}