use std::sync::Arc;
use rmcp::model::{Tool, ToolAnnotations};
use serde_json::{Map, Value};
mod granular;
pub use granular::{granular_tool_defs, unified_tool_defs};
pub fn tool_def(name: &'static str, description: &'static str, schema_value: Value) -> Tool {
let mut schema: Map<String, Value> = match sanitize_schema(schema_value) {
Value::Object(map) => map,
_ => Map::new(),
};
normalize_for_strict_validators(&mut schema);
Tool::new(name, description, Arc::new(schema))
}
fn sanitize_schema(schema: Value) -> Value {
let Value::Object(mut schema) = schema else {
return schema;
};
schema.remove("oneOf");
schema.remove("allOf");
schema.remove("anyOf");
schema.remove("if");
schema.remove("then");
schema.remove("else");
Value::Object(schema)
}
pub const READONLY_TOOL_NAMES: &[&str] = &[
"ctx_read",
"ctx_tree",
"ctx_glob",
"ctx_callgraph",
"ctx_overview",
"ctx_expand",
"ctx_explore",
"ctx_delta",
"ctx_url_read",
"ctx_benchmark",
"ctx_analyze",
"ctx_discover",
"ctx_response",
];
pub const DESTRUCTIVE_TOOL_NAMES: &[&str] = &["ctx_shell", "ctx_execute", "ctx_patch"];
pub fn apply_tool_annotations(tools: Vec<Tool>) -> Vec<Tool> {
tools
.into_iter()
.map(|t| {
let name = t.name.as_ref();
if READONLY_TOOL_NAMES.contains(&name) {
t.annotate(
ToolAnnotations::new()
.read_only(true)
.destructive(false)
.idempotent(true),
)
} else if DESTRUCTIVE_TOOL_NAMES.contains(&name) {
t.annotate(ToolAnnotations::new().destructive(true))
} else {
t
}
})
.collect()
}
pub fn normalize_for_strict_validators(schema: &mut Map<String, Value>) {
let is_object = schema.get("type").and_then(Value::as_str) == Some("object");
let is_array = schema.get("type").and_then(Value::as_str) == Some("array");
if is_object && schema.contains_key("properties") && !schema.contains_key("required") {
schema.insert("required".into(), Value::Array(Vec::new()));
}
if is_array && !schema.contains_key("items") {
schema.insert("items".into(), Value::Object(Map::new()));
}
if let Some(Value::Object(props)) = schema.get_mut("properties") {
for prop in props.values_mut() {
if let Value::Object(p) = prop {
normalize_for_strict_validators(p);
}
}
}
if let Some(Value::Object(items)) = schema.get_mut("items") {
normalize_for_strict_validators(items);
}
if let Some(Value::Object(ap)) = schema.get_mut("additionalProperties") {
normalize_for_strict_validators(ap);
}
for combinator in ["anyOf", "oneOf", "allOf"] {
if let Some(Value::Array(branches)) = schema.get_mut(combinator) {
for branch in branches.iter_mut() {
if let Value::Object(b) = branch {
normalize_for_strict_validators(b);
}
}
}
}
for keyword in ["if", "then", "else", "not"] {
if let Some(Value::Object(sub)) = schema.get_mut(keyword) {
normalize_for_strict_validators(sub);
}
}
}
pub const CORE_TOOL_NAMES: &[&str] = &[
"ctx_read",
"ctx_shell",
"shell",
"ctx_search",
"ctx_glob",
"ctx_tree",
"ctx_session",
"ctx_compose",
"ctx_callgraph",
"ctx_patch",
"ctx_call",
"ctx_expand",
];
pub fn core_tool_names() -> &'static [&'static str] {
CORE_TOOL_NAMES
}
pub fn lazy_tool_defs() -> Vec<Tool> {
let all = granular_tool_defs();
all.into_iter()
.filter(|t| CORE_TOOL_NAMES.contains(&t.name.as_ref()))
.collect()
}
pub fn discover_tools(query: &str) -> String {
let all = crate::server::registry::build_registry().tool_defs();
let query_lower = query.to_lowercase();
let matches: Vec<(String, String)> = all
.iter()
.filter_map(|t| {
let name = t.name.as_ref();
let desc = t.description.as_deref().unwrap_or("");
if name.to_lowercase().contains(&query_lower)
|| desc.to_lowercase().contains(&query_lower)
{
Some((name.to_string(), desc.to_string()))
} else {
None
}
})
.collect();
if matches.is_empty() {
return format!(
"No tools found matching '{query}'. Try broader terms like: graph, cost, session, search, compress, agent, workflow, gain."
);
}
let mut out = format!("{} tools matching '{query}':\n", matches.len());
for (name, desc) in &matches {
let first = desc.lines().next().unwrap_or(desc);
let short = if first.len() > 80 {
&first[..first.floor_char_boundary(80)]
} else {
first
};
out.push_str(&format!(" {name} — {short}\n"));
}
out.push_str(
"\nIf your MCP client registers tools only once at startup (static tools/list), \
use ctx_call (available in lazy mode) to invoke discovered tools:\n\
ctx_call {\"name\":\"ctx_graph\",\"arguments\":{\"action\":\"status\"}}\n",
);
out
}
pub fn is_full_mode() -> bool {
std::env::var("LEAN_CTX_FULL_TOOLS").is_ok_and(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
|| std::env::var("LEAN_CTX_LAZY_TOOLS")
.is_ok_and(|v| v == "0" || v.eq_ignore_ascii_case("false"))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::sanitize_schema;
#[test]
fn sanitize_schema_strips_all_combinators() {
let sanitized = sanitize_schema(json!({
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["base"],
"oneOf": [
{"required": ["command", "cwd"]},
{"required": ["command", "timeout"]}
],
"allOf": [
{"if": {"properties": {"action": {"const": "x"}}}, "then": {"required": ["y"]}}
],
"anyOf": [{"type": "object"}],
"if": {"properties": {"action": {"const": "z"}}},
"then": {"required": ["w"]}
}));
assert_eq!(
sanitized,
json!({
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["base"]
})
);
}
#[test]
fn sanitize_schema_preserves_schema_without_one_of() {
let schema = json!({
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]
});
assert_eq!(sanitize_schema(schema.clone()), schema);
}
#[test]
fn sanitize_strips_root_anyof_with_required_only_branches() {
let schema = json!({
"type": "object",
"properties": { "a": { "type": "string" } },
"anyOf": [
{ "required": ["a"] },
{ "required": ["b", "c"] }
]
});
let result = sanitize_schema(schema);
assert!(result.get("anyOf").is_none());
assert!(result.get("properties").is_some());
}
#[test]
fn sanitize_strips_anyof_with_typed_branches() {
let schema = json!({
"type": "object",
"anyOf": [
{ "type": "object", "properties": { "a": { "type": "string" } } }
]
});
let result = sanitize_schema(schema);
assert!(result.get("anyOf").is_none());
}
}