use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use terraphim_automata::find_matches;
use terraphim_types::{McpToolEntry, NormalizedTerm, NormalizedTermValue, Thesaurus};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolIndex {
tools: Vec<McpToolEntry>,
index_path: PathBuf,
}
impl McpToolIndex {
pub fn new(index_path: PathBuf) -> Self {
Self {
tools: Vec::new(),
index_path,
}
}
pub fn add_tool(&mut self, tool: McpToolEntry) {
self.tools.push(tool);
}
pub fn search(&self, query: &str) -> Vec<&McpToolEntry> {
if self.tools.is_empty() || query.trim().is_empty() {
return Vec::new();
}
let mut thesaurus = Thesaurus::new("query_terms".to_string());
let keywords: Vec<&str> = query.split_whitespace().collect();
for (idx, keyword) in keywords.iter().enumerate() {
if keyword.len() >= 2 {
let key = NormalizedTermValue::from(*keyword);
let term = NormalizedTerm::new(idx as u64, key.clone());
thesaurus.insert(key, term);
}
}
if thesaurus.is_empty() {
return Vec::new();
}
let mut results: Vec<&McpToolEntry> = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
for (tool_idx, tool) in self.tools.iter().enumerate() {
let search_text = tool.search_text();
match find_matches(&search_text, thesaurus.clone(), false) {
Ok(matches) => {
if !matches.is_empty() && seen_ids.insert(tool_idx) {
results.push(&self.tools[tool_idx]);
}
}
Err(_) => continue,
}
}
results
}
pub fn save(&self) -> Result<(), std::io::Error> {
if let Some(parent) = self.index_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
std::fs::write(&self.index_path, json)?;
Ok(())
}
pub fn load(index_path: PathBuf) -> Result<Self, std::io::Error> {
let json = std::fs::read_to_string(&index_path)?;
let index: Self = serde_json::from_str(&json)?;
Ok(index)
}
pub fn tool_count(&self) -> usize {
self.tools.len()
}
pub fn tools(&self) -> &[McpToolEntry] {
&self.tools
}
pub fn index_path(&self) -> &Path {
&self.index_path
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
fn create_test_tool(name: &str, description: &str, server: &str) -> McpToolEntry {
McpToolEntry::new(name, description, server)
}
#[test]
fn test_tool_index_add_and_search() {
let mut index = McpToolIndex::new(PathBuf::from("/tmp/test-mcp-tools.json"));
let tool1 = create_test_tool(
"search_files",
"Search for files matching a pattern",
"filesystem",
);
let tool2 = create_test_tool("read_file", "Read file contents", "filesystem");
let tool3 = create_test_tool("grep_search", "Search text using grep", "search");
index.add_tool(tool1);
index.add_tool(tool2);
index.add_tool(tool3);
let results = index.search("file");
assert!(!results.is_empty());
assert!(results.iter().any(|t| t.name == "search_files"));
assert!(results.iter().any(|t| t.name == "read_file"));
}
#[test]
fn test_tool_index_save_and_load() {
let temp_dir = std::env::temp_dir();
let index_path = temp_dir.join("test-mcp-index.json");
{
let mut index = McpToolIndex::new(index_path.clone());
let tool = create_test_tool("search_files", "Search for files", "filesystem")
.with_tags(vec!["search".to_string(), "filesystem".to_string()]);
index.add_tool(tool);
index.save().expect("Failed to save index");
}
{
let index = McpToolIndex::load(index_path.clone()).expect("Failed to load index");
assert_eq!(index.tool_count(), 1);
assert_eq!(index.tools[0].name, "search_files");
assert_eq!(index.tools[0].tags, vec!["search", "filesystem"]);
}
let _ = std::fs::remove_file(&index_path);
}
#[test]
fn test_tool_index_empty_search() {
let index = McpToolIndex::new(PathBuf::from("/tmp/test-empty.json"));
let results = index.search("anything");
assert!(results.is_empty());
}
#[test]
fn test_tool_index_count() {
let mut index = McpToolIndex::new(PathBuf::from("/tmp/test-count.json"));
assert_eq!(index.tool_count(), 0);
index.add_tool(create_test_tool("tool1", "First tool", "server1"));
assert_eq!(index.tool_count(), 1);
index.add_tool(create_test_tool("tool2", "Second tool", "server1"));
assert_eq!(index.tool_count(), 2);
}
#[test]
fn test_search_partial_match() {
let mut index = McpToolIndex::new(PathBuf::from("/tmp/test-partial.json"));
index.add_tool(create_test_tool(
"search_files",
"Search for files",
"filesystem",
));
index.add_tool(create_test_tool(
"search_code",
"Search code repositories",
"code",
));
index.add_tool(create_test_tool(
"read_file",
"Read file contents",
"filesystem",
));
let results = index.search("search");
assert!(results.iter().any(|t| t.name == "search_files"));
assert!(results.iter().any(|t| t.name == "search_code"));
assert!(!results.iter().any(|t| t.name == "read_file"));
}
#[test]
fn test_search_description_match() {
let mut index = McpToolIndex::new(PathBuf::from("/tmp/test-desc.json"));
index.add_tool(create_test_tool(
"tool_a",
"This tool reads data from files",
"server",
));
index.add_tool(create_test_tool(
"tool_b",
"This tool writes data to database",
"server",
));
let results = index.search("reads");
assert!(results.iter().any(|t| t.name == "tool_a"));
assert!(!results.iter().any(|t| t.name == "tool_b"));
}
#[test]
fn test_discovery_latency_benchmark() {
let mut index = McpToolIndex::new(PathBuf::from("/tmp/test-benchmark.json"));
for i in 0..100 {
let tool = create_test_tool(
&format!("tool_{}", i),
&format!("Tool number {} does something useful", i),
&format!("server_{}", i % 10),
);
index.add_tool(tool);
}
let start = Instant::now();
let results = index.search("tool_50");
let elapsed = start.elapsed();
assert!(!results.is_empty(), "Should find at least one tool");
assert!(
elapsed.as_millis() < 70,
"Search should complete in under 70ms, took {:?}",
elapsed
);
}
#[test]
fn test_search_with_tags() {
let mut index = McpToolIndex::new(PathBuf::from("/tmp/test-tags.json"));
let tool1 = create_test_tool("search_files", "Search for files", "filesystem")
.with_tags(vec!["search".to_string(), "files".to_string()]);
let tool2 = create_test_tool("grep_search", "Search with grep", "search")
.with_tags(vec!["search".to_string(), "text".to_string()]);
index.add_tool(tool1);
index.add_tool(tool2);
let results = index.search("text");
assert!(results.iter().any(|t| t.name == "grep_search"));
}
#[test]
fn test_empty_query_returns_empty() {
let mut index = McpToolIndex::new(PathBuf::from("/tmp/test-empty-query.json"));
index.add_tool(create_test_tool("tool1", "Description", "server"));
let results = index.search("");
assert!(results.is_empty());
}
#[test]
fn test_new_creates_empty_index() {
let index = McpToolIndex::new(PathBuf::from("/tmp/test-new.json"));
assert_eq!(index.tool_count(), 0);
assert!(index.tools().is_empty());
}
}