use serde::Deserialize;
use serde_json::{Value, json};
use crate::mcp::manager::McpManager;
use crate::tools::tool_index::ToolIndex;
#[derive(Debug, Deserialize)]
pub struct ToolSearchInput {
pub query: String,
}
pub fn definition() -> Value {
json!({
"type": "function",
"function": {
"name": "tool_search",
"description": "Search available tools (MCP servers, skills, agents) by keyword using BM25 relevance ranking. Returns full tool schemas for matching MCP tools and metadata for skills/agents. Use when tools are deferred (not listed in the tools array) or when you need to discover the right tool for a task.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Natural language query or keywords to find relevant tools"
}
},
"required": ["query"]
}
}
})
}
pub fn execute(input: ToolSearchInput, tool_index: &ToolIndex) -> crate::common::Result<String> {
execute_with_mcp(input, tool_index, None)
}
pub fn execute_with_mcp(
input: ToolSearchInput,
tool_index: &ToolIndex,
mcp_manager: Option<&McpManager>,
) -> crate::common::Result<String> {
let results = tool_index.search(&input.query, 10);
let mut seen_ids: std::collections::HashSet<String> =
results.iter().map(|r| r.id.clone()).collect();
let mut entries: Vec<Value> = results
.iter()
.map(|r| {
let mut entry = json!({
"id": r.id,
"source": format!("{:?}", r.source),
"score": format!("{:.2}", r.score),
"description": r.description,
});
if let Some(ref schema) = r.schema {
entry["schema"] = schema.clone();
}
entry
})
.collect();
if let Some(mgr) = mcp_manager
&& mgr.is_deferred_mode()
{
for schema in mgr.search_tools(&input.query) {
let name = schema["function"]["name"]
.as_str()
.unwrap_or("")
.to_string();
if !name.is_empty() && seen_ids.insert(name.clone()) {
let desc = schema["function"]["description"]
.as_str()
.unwrap_or("")
.to_string();
entries.push(json!({
"id": name,
"source": "Mcp",
"score": "live",
"description": desc,
"schema": schema,
}));
}
}
}
if entries.is_empty() {
return Ok(format!(
"No tools found matching '{}'. Try broader or different keywords.",
input.query
));
}
let output = json!({
"match_count": entries.len(),
"tools": entries,
});
serde_json::to_string_pretty(&output)
.map_err(|e| crate::common::AgentError::Internal(format!("JSON serialization failed: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::tool_index::{ToolIndex, ToolSource};
use serde_json::json;
fn make_index() -> ToolIndex {
let mut idx = ToolIndex::new();
idx.upsert_entry(
"mcp__ctx__docs".into(),
ToolSource::Mcp,
"mcp__ctx__docs",
"Query documentation for an API",
None,
Some(json!({"type": "function", "function": {"name": "mcp__ctx__docs"}})),
);
idx.upsert_entry(
"git-commit".into(),
ToolSource::Skill,
"git-commit",
"Create a conventional commit message",
None,
None,
);
idx
}
#[test]
fn test_execute_returns_results() {
let idx = make_index();
let input = ToolSearchInput {
query: "documentation API".into(),
};
let result = execute(input, &idx).unwrap();
assert!(result.contains("mcp__ctx__docs"));
}
#[test]
fn test_execute_no_results() {
let idx = make_index();
let input = ToolSearchInput {
query: "quantum physics xyz".into(),
};
let result = execute(input, &idx).unwrap();
assert!(result.contains("No tools found"));
}
#[test]
fn test_execute_with_mcp_no_manager() {
let idx = make_index();
let input = ToolSearchInput {
query: "commit".into(),
};
let result = execute_with_mcp(input, &idx, None).unwrap();
assert!(result.contains("git-commit"));
}
}