use crate::bm25::{Bm25Index, IndexOptions};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[cfg(feature = "schema")]
use schemars::JsonSchema;
const STOP_WORDS: &[&str] = &[
"a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "he", "in", "is", "it",
"its", "of", "on", "or", "that", "the", "to", "was", "were", "will", "with", "this", "but",
"they", "have", "had", "what", "when", "where", "who", "which", "why", "how", "all", "each",
"every", "both", "few", "more", "most", "other", "some", "such", "no", "nor", "not", "only",
"own", "same", "so", "than", "too", "very", "just", "can", "could", "should", "would", "may",
"might", "must", "shall", "about", "above", "after", "again", "against", "below", "between",
"into", "through", "during", "before", "under", "over",
];
fn preprocess_for_search(text: &str) -> String {
let mut result = text.to_string();
result = strip_jmespath_literals(&result);
result = expand_regex_patterns(&result);
result = expand_identifiers(&result);
result.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn strip_jmespath_literals(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '`' {
let mut inner = String::new();
for inner_c in chars.by_ref() {
if inner_c == '`' {
break;
}
inner.push(inner_c);
}
let trimmed = inner.trim();
if trimmed.starts_with('"') && trimmed.ends_with('"') {
let content = &trimmed[1..trimmed.len() - 1];
let expanded = expand_escape_sequences(content);
result.push(' ');
result.push_str(&expanded);
result.push(' ');
} else {
result.push(' ');
result.push_str(trimmed);
result.push(' ');
}
} else {
result.push(c);
}
}
result
}
fn expand_escape_sequences(text: &str) -> String {
text.replace("\\n", " newline linebreak ")
.replace("\\r", " return ")
.replace("\\t", " tab ")
.replace("\\s", " whitespace space ")
.replace("\\d", " digit number numeric ")
.replace("\\w", " word alphanumeric ")
.replace("\\b", " boundary ")
.replace("\\\\", " ")
}
fn expand_regex_patterns(text: &str) -> String {
text
.replace("[0-9]", " digit number ")
.replace("[a-z]", " letter lowercase ")
.replace("[A-Z]", " letter uppercase ")
.replace("[a-zA-Z]", " letter alphabetic ")
.replace("[^>]", " ")
.replace(".*", " any anything ")
.replace(".+", " one more any ")
.replace("\\d+", " digits numbers numeric ")
.replace("\\w+", " words alphanumeric ")
.replace("\\s+", " whitespace spaces ")
.replace("\\S+", " nonwhitespace ")
.replace(
['[', ']', '(', ')', '{', '}', '*', '+', '?', '^', '$', '|'],
" ",
)
}
fn expand_identifiers(text: &str) -> String {
let mut result = String::with_capacity(text.len() * 2);
for word in text.split_whitespace() {
if word.contains('_') {
for part in word.split('_') {
if !part.is_empty() {
result.push_str(part);
result.push(' ');
}
}
result.push_str(word);
result.push(' ');
}
else if word.chars().any(|c| c.is_uppercase()) && word.chars().any(|c| c.is_lowercase()) {
let mut prev_was_upper = false;
let mut current_word = String::new();
for c in word.chars() {
if c.is_uppercase() && !prev_was_upper && !current_word.is_empty() {
result.push_str(¤t_word.to_lowercase());
result.push(' ');
current_word.clear();
}
current_word.push(c);
prev_was_upper = c.is_uppercase();
}
if !current_word.is_empty() {
result.push_str(¤t_word.to_lowercase());
result.push(' ');
}
result.push_str(word);
result.push(' ');
} else {
result.push_str(word);
result.push(' ');
}
}
result
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct DiscoverySpec {
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
pub server: ServerInfo,
pub tools: Vec<ToolSpec>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub categories: HashMap<String, CategoryInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ServerInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ToolSpec {
pub name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subcategory: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params: Vec<ParamSpec>,
#[serde(skip_serializing_if = "Option::is_none")]
pub returns: Option<ReturnSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<ExampleSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub related: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub since: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stability: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ParamSpec {
pub name: String,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub param_type: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ReturnSpec {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub return_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ExampleSpec {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct CategoryInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subcategories: Vec<String>,
}
#[derive(Debug)]
pub struct DiscoveryRegistry {
servers: HashMap<String, DiscoverySpec>,
tools: HashMap<String, (String, ToolSpec)>,
index: Option<Bm25Index>,
}
impl Default for DiscoveryRegistry {
fn default() -> Self {
Self::new()
}
}
impl DiscoveryRegistry {
pub fn new() -> Self {
Self {
servers: HashMap::new(),
tools: HashMap::new(),
index: None,
}
}
pub fn register(&mut self, spec: DiscoverySpec, replace: bool) -> RegistrationResult {
let server_name = spec.server.name.clone();
if self.servers.contains_key(&server_name) && !replace {
return RegistrationResult {
ok: false,
tools_indexed: 0,
warnings: vec![format!(
"Server '{}' already registered. Use replace=true to update.",
server_name
)],
};
}
if replace {
self.tools.retain(|_, (srv, _)| srv != &server_name);
}
let mut warnings = Vec::new();
let mut tools_added = 0;
for tool in &spec.tools {
let tool_id = format!("{}:{}", server_name, tool.name);
if self.tools.contains_key(&tool_id) && !replace {
warnings.push(format!("Tool '{}' already exists, skipping", tool_id));
continue;
}
self.tools
.insert(tool_id, (server_name.clone(), tool.clone()));
tools_added += 1;
}
self.servers.insert(server_name, spec);
self.rebuild_index();
RegistrationResult {
ok: true,
tools_indexed: tools_added,
warnings,
}
}
pub fn unregister(&mut self, server_name: &str) -> bool {
if self.servers.remove(server_name).is_some() {
self.tools.retain(|_, (srv, _)| srv != server_name);
self.rebuild_index();
true
} else {
false
}
}
fn rebuild_index(&mut self) {
if self.tools.is_empty() {
self.index = None;
return;
}
let docs: Vec<Value> = self
.tools
.iter()
.map(|(id, (server, tool))| {
let summary = tool.summary.as_deref().unwrap_or("");
let description = tool.description.as_deref().unwrap_or("");
let expanded_summary = preprocess_for_search(summary);
let expanded_description = preprocess_for_search(description);
let examples_text: String = tool
.examples
.iter()
.filter_map(|ex| ex.description.as_ref())
.map(|d| preprocess_for_search(d))
.collect::<Vec<_>>()
.join(" ");
serde_json::json!({
"id": id,
"server": server,
"name": tool.name,
"aliases": tool.aliases.join(" "),
"category": tool.category.as_deref().unwrap_or(""),
"tags": tool.tags.join(" "),
"summary": summary,
"description": description,
"params": tool.params.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(" "),
"expanded_summary": expanded_summary,
"expanded_description": expanded_description,
"expanded_examples": examples_text,
})
})
.collect();
let options = IndexOptions {
fields: vec![
"name".to_string(),
"aliases".to_string(),
"category".to_string(),
"tags".to_string(),
"summary".to_string(),
"description".to_string(),
"params".to_string(),
"expanded_summary".to_string(),
"expanded_description".to_string(),
"expanded_examples".to_string(),
],
id_field: Some("id".to_string()),
stopwords: STOP_WORDS.iter().map(|s| s.to_string()).collect(),
..Default::default()
};
self.index = Some(Bm25Index::build(&docs, options));
}
pub fn query(&self, query: &str, top_k: usize) -> Vec<ToolQueryResult> {
let Some(index) = &self.index else {
return Vec::new();
};
let results = index.search(query, top_k);
results
.into_iter()
.filter_map(|r| {
let (server, tool) = self.tools.get(&r.id)?;
Some(ToolQueryResult {
id: r.id,
server: server.clone(),
tool: tool.clone(),
score: r.score,
matches: r.matches,
})
})
.collect()
}
pub fn similar(&self, tool_id: &str, top_k: usize) -> Vec<ToolQueryResult> {
let Some(index) = &self.index else {
return Vec::new();
};
let results = index.similar(tool_id, top_k);
results
.into_iter()
.filter_map(|r| {
let (server, tool) = self.tools.get(&r.id)?;
Some(ToolQueryResult {
id: r.id,
server: server.clone(),
tool: tool.clone(),
score: r.score,
matches: r.matches,
})
})
.collect()
}
pub fn list_servers(&self) -> Vec<ServerSummary> {
self.servers
.iter()
.map(|(name, spec)| ServerSummary {
name: name.clone(),
version: spec.server.version.clone(),
description: spec.server.description.clone(),
tool_count: spec.tools.len(),
})
.collect()
}
pub fn list_categories(&self) -> HashMap<String, CategorySummary> {
let mut categories: HashMap<String, CategorySummary> = HashMap::new();
for (server, tool) in self.tools.values() {
if let Some(cat) = &tool.category {
let entry = categories.entry(cat.clone()).or_insert(CategorySummary {
name: cat.clone(),
tool_count: 0,
servers: Vec::new(),
subcategories: Vec::new(),
});
entry.tool_count += 1;
if !entry.servers.contains(server) {
entry.servers.push(server.clone());
}
if let Some(subcat) = tool
.subcategory
.as_ref()
.filter(|s| !entry.subcategories.contains(s))
{
entry.subcategories.push(subcat.clone());
}
}
}
categories
}
pub fn index_stats(&self) -> Option<IndexStats> {
let index = self.index.as_ref()?;
Some(IndexStats {
doc_count: index.doc_count,
term_count: index.terms.len(),
avg_doc_length: index.avg_doc_length,
server_count: self.servers.len(),
top_terms: index.terms().into_iter().take(20).collect(),
})
}
pub fn get_schema() -> Value {
serde_json::json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://jpx.dev/schemas/mcp-discovery/v1.json",
"title": "MCP Discovery Spec",
"description": "Schema for registering MCP server capabilities with jpx",
"type": "object",
"required": ["server", "tools"],
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference"
},
"server": {
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string", "description": "Server name"},
"version": {"type": "string", "description": "Server version"},
"description": {"type": "string", "description": "Server description"}
}
},
"tools": {
"type": "array",
"items": {
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string", "description": "Tool name"},
"aliases": {"type": "array", "items": {"type": "string"}},
"category": {"type": "string"},
"subcategory": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}},
"summary": {"type": "string", "description": "Short summary"},
"description": {"type": "string", "description": "Full description"},
"params": {
"type": "array",
"items": {
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string"},
"type": {"type": "string"},
"required": {"type": "boolean"},
"description": {"type": "string"},
"enum": {"type": "array", "items": {"type": "string"}},
"default": {}
}
}
},
"returns": {
"type": "object",
"properties": {
"type": {"type": "string"},
"description": {"type": "string"}
}
},
"examples": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"args": {},
"result": {}
}
}
},
"related": {"type": "array", "items": {"type": "string"}},
"since": {"type": "string"},
"stability": {"type": "string", "enum": ["stable", "beta", "deprecated"]}
}
}
},
"categories": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"description": {"type": "string"},
"subcategories": {"type": "array", "items": {"type": "string"}}
}
}
}
}
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistrationResult {
pub ok: bool,
pub tools_indexed: usize,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolQueryResult {
pub id: String,
pub server: String,
pub tool: ToolSpec,
pub score: f64,
pub matches: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerSummary {
pub name: String,
pub version: Option<String>,
pub description: Option<String>,
pub tool_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CategorySummary {
pub name: String,
pub tool_count: usize,
pub servers: Vec<String>,
pub subcategories: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexStats {
pub doc_count: usize,
pub term_count: usize,
pub avg_doc_length: f64,
pub server_count: usize,
pub top_terms: Vec<(String, usize)>,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_spec() -> DiscoverySpec {
serde_json::from_value(serde_json::json!({
"server": {
"name": "redisctl",
"version": "0.5.0",
"description": "Redis Enterprise management"
},
"tools": [
{
"name": "create_cluster",
"category": "clusters",
"tags": ["write", "provisioning"],
"summary": "Create a new Redis cluster",
"description": "Creates a new Redis Enterprise cluster with specified configuration"
},
{
"name": "delete_cluster",
"category": "clusters",
"tags": ["write", "destructive"],
"summary": "Delete a cluster",
"description": "Permanently deletes a Redis cluster"
},
{
"name": "list_backups",
"category": "backups",
"tags": ["read"],
"summary": "List all backups",
"description": "Lists all available backups for a cluster"
}
]
})).unwrap()
}
#[test]
fn test_register_spec() {
let mut registry = DiscoveryRegistry::new();
let spec = sample_spec();
let result = registry.register(spec, false);
assert!(result.ok);
assert_eq!(result.tools_indexed, 3);
assert!(result.warnings.is_empty());
}
#[test]
fn test_query_tools() {
let mut registry = DiscoveryRegistry::new();
registry.register(sample_spec(), false);
let results = registry.query("cluster", 10);
assert!(!results.is_empty());
let top_names: Vec<_> = results
.iter()
.take(2)
.map(|r| r.tool.name.as_str())
.collect();
assert!(top_names.contains(&"create_cluster"));
assert!(top_names.contains(&"delete_cluster"));
}
#[test]
fn test_query_by_tag() {
let mut registry = DiscoveryRegistry::new();
registry.register(sample_spec(), false);
let results = registry.query("read", 10);
assert_eq!(results.len(), 1);
assert_eq!(results[0].tool.name, "list_backups");
}
#[test]
fn test_list_servers() {
let mut registry = DiscoveryRegistry::new();
registry.register(sample_spec(), false);
let servers = registry.list_servers();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "redisctl");
assert_eq!(servers[0].tool_count, 3);
}
#[test]
fn test_list_categories() {
let mut registry = DiscoveryRegistry::new();
registry.register(sample_spec(), false);
let categories = registry.list_categories();
assert_eq!(categories.len(), 2);
assert!(categories.contains_key("clusters"));
assert!(categories.contains_key("backups"));
assert_eq!(categories.get("clusters").unwrap().tool_count, 2);
}
#[test]
fn test_unregister() {
let mut registry = DiscoveryRegistry::new();
registry.register(sample_spec(), false);
assert!(registry.unregister("redisctl"));
assert!(registry.list_servers().is_empty());
assert!(registry.query("cluster", 10).is_empty());
}
#[test]
fn test_replace_registration() {
let mut registry = DiscoveryRegistry::new();
registry.register(sample_spec(), false);
let result = registry.register(sample_spec(), false);
assert!(!result.ok);
let result = registry.register(sample_spec(), true);
assert!(result.ok);
}
#[test]
fn test_similar_tools() {
let mut registry = DiscoveryRegistry::new();
registry.register(sample_spec(), false);
let similar = registry.similar("redisctl:create_cluster", 10);
assert!(!similar.is_empty());
assert_eq!(similar[0].tool.name, "delete_cluster");
}
#[test]
fn test_minimal_spec() {
let minimal: DiscoverySpec = serde_json::from_value(serde_json::json!({
"server": {"name": "minimal"},
"tools": [{"name": "foo"}]
}))
.unwrap();
let mut registry = DiscoveryRegistry::new();
let result = registry.register(minimal, false);
assert!(result.ok);
assert_eq!(result.tools_indexed, 1);
}
#[test]
fn test_get_schema() {
let schema = DiscoveryRegistry::get_schema();
assert!(schema.get("$schema").is_some());
assert!(schema.get("properties").is_some());
}
#[test]
fn test_index_stats() {
let mut registry = DiscoveryRegistry::new();
registry.register(sample_spec(), false);
let stats = registry.index_stats().unwrap();
assert_eq!(stats.doc_count, 3);
assert_eq!(stats.server_count, 1);
assert!(stats.term_count > 0);
}
#[test]
fn test_strip_jmespath_literals() {
assert!(strip_jmespath_literals(r#"split text on `"\n"` newlines"#).contains("newline"));
let result = strip_jmespath_literals(r#"match `"\\d+"` digits"#);
assert!(result.contains("digit"));
let result = strip_jmespath_literals(r#"use `"\t"` for tabs and `"\n"` for lines"#);
assert!(result.contains("tab"));
assert!(result.contains("newline"));
let result = strip_jmespath_literals(r#"literal `123` number"#);
assert!(result.contains("123"));
}
#[test]
fn test_expand_escape_sequences() {
assert!(expand_escape_sequences(r"\n").contains("newline"));
assert!(expand_escape_sequences(r"\t").contains("tab"));
assert!(expand_escape_sequences(r"\d").contains("digit"));
assert!(expand_escape_sequences(r"\w").contains("word"));
assert!(expand_escape_sequences(r"\s").contains("whitespace"));
}
#[test]
fn test_expand_regex_patterns() {
assert!(expand_regex_patterns(r"\d+").contains("digits"));
assert!(expand_regex_patterns(r"\w+").contains("words"));
assert!(expand_regex_patterns(r"[0-9]").contains("digit"));
assert!(expand_regex_patterns(r"[a-zA-Z]").contains("letter"));
assert!(expand_regex_patterns(r".*").contains("any"));
let result = expand_regex_patterns(r"foo[bar]+baz");
assert!(!result.contains('['));
assert!(!result.contains(']'));
assert!(!result.contains('+'));
}
#[test]
fn test_expand_identifiers() {
let result = expand_identifiers("get_user_info");
assert!(result.contains("get"));
assert!(result.contains("user"));
assert!(result.contains("info"));
assert!(result.contains("get_user_info"));
let result = expand_identifiers("getUserInfo");
assert!(result.contains("get"));
assert!(result.contains("user"));
assert!(result.contains("info"));
assert!(result.contains("getUserInfo"));
let result = expand_identifiers("simple");
assert!(result.contains("simple"));
}
#[test]
fn test_preprocess_for_search_integration() {
let input = r#"Split on `"\n"` to get lines, use regex_extract for \d+ numbers"#;
let result = preprocess_for_search(input);
assert!(result.contains("newline") || result.contains("linebreak"));
assert!(result.contains("digit") || result.contains("number"));
assert!(result.contains("regex"));
assert!(result.contains("extract"));
assert!(!result.contains(" "));
}
#[test]
fn test_preprocess_preserves_search_terms() {
let input = "Create a new database connection";
let result = preprocess_for_search(input);
assert!(result.contains("Create"));
assert!(result.contains("database"));
assert!(result.contains("connection"));
}
#[test]
fn test_search_with_preprocessed_content() {
let spec: DiscoverySpec = serde_json::from_value(serde_json::json!({
"server": {"name": "text-tools"},
"tools": [
{
"name": "split_lines",
"summary": r#"Split text on newlines using `"\n"` delimiter"#,
"description": r#"Splits input string on newline characters. Use split(@, `"\n"`) syntax."#
},
{
"name": "extract_numbers",
"summary": r#"Extract numeric patterns with regex `"\\d+"`"#,
"description": r#"Uses regex_extract to find all \d+ digit sequences in text."#
}
]
}))
.unwrap();
let mut registry = DiscoveryRegistry::new();
registry.register(spec, false);
let results = registry.query("newline", 10);
assert!(!results.is_empty());
assert_eq!(results[0].tool.name, "split_lines");
let results = registry.query("digit", 10);
assert!(!results.is_empty());
assert_eq!(results[0].tool.name, "extract_numbers");
}
#[test]
fn test_register_duplicate_tool_names() {
let mut registry = DiscoveryRegistry::new();
let spec_a: DiscoverySpec = serde_json::from_value(serde_json::json!({
"server": {"name": "server-a"},
"tools": [{"name": "do_thing", "summary": "Does a thing from server A"}]
}))
.unwrap();
let spec_b: DiscoverySpec = serde_json::from_value(serde_json::json!({
"server": {"name": "server-b"},
"tools": [{"name": "do_thing", "summary": "Does a thing from server B"}]
}))
.unwrap();
let result_a = registry.register(spec_a, false);
let result_b = registry.register(spec_b, false);
assert!(result_a.ok);
assert!(result_b.ok);
assert_eq!(result_a.tools_indexed, 1);
assert_eq!(result_b.tools_indexed, 1);
assert!(registry.tools.contains_key("server-a:do_thing"));
assert!(registry.tools.contains_key("server-b:do_thing"));
let results = registry.query("do_thing", 10);
assert_eq!(results.len(), 2);
let servers: Vec<_> = results.iter().map(|r| r.server.as_str()).collect();
assert!(servers.contains(&"server-a"));
assert!(servers.contains(&"server-b"));
}
#[test]
fn test_query_no_results() {
let mut registry = DiscoveryRegistry::new();
registry.register(sample_spec(), false);
let results = registry.query("xyznonexistent", 10);
assert!(results.is_empty());
}
#[test]
fn test_query_empty_registry() {
let registry = DiscoveryRegistry::new();
let results = registry.query("cluster", 10);
assert!(results.is_empty());
}
#[test]
fn test_index_stats_empty_registry() {
let registry = DiscoveryRegistry::new();
assert!(registry.index_stats().is_none());
}
#[test]
fn test_category_filtering_edge_case() {
let spec: DiscoverySpec = serde_json::from_value(serde_json::json!({
"server": {"name": "mixed-server"},
"tools": [
{
"name": "categorized_tool",
"category": "utils",
"summary": "A tool with a category"
},
{
"name": "uncategorized_tool",
"summary": "A tool without a category"
}
]
}))
.unwrap();
let mut registry = DiscoveryRegistry::new();
registry.register(spec, false);
let categories = registry.list_categories();
assert_eq!(categories.len(), 1);
assert!(categories.contains_key("utils"));
assert_eq!(categories.get("utils").unwrap().tool_count, 1);
}
#[test]
fn test_unregister_nonexistent() {
let mut registry = DiscoveryRegistry::new();
assert!(!registry.unregister("never-registered"));
}
#[test]
fn test_multiple_servers() {
let mut registry = DiscoveryRegistry::new();
let spec_redis = sample_spec();
let spec_postgres: DiscoverySpec = serde_json::from_value(serde_json::json!({
"server": {
"name": "pgctl",
"version": "1.0.0",
"description": "PostgreSQL management"
},
"tools": [
{
"name": "create_database",
"category": "databases",
"tags": ["write"],
"summary": "Create a new PostgreSQL database",
"description": "Creates a new PostgreSQL database with specified configuration"
},
{
"name": "list_tables",
"category": "tables",
"tags": ["read"],
"summary": "List all tables in a database",
"description": "Lists all tables in a PostgreSQL database"
}
]
}))
.unwrap();
registry.register(spec_redis, false);
registry.register(spec_postgres, false);
let servers = registry.list_servers();
assert_eq!(servers.len(), 2);
let server_names: Vec<_> = servers.iter().map(|s| s.name.as_str()).collect();
assert!(server_names.contains(&"redisctl"));
assert!(server_names.contains(&"pgctl"));
let results = registry.query("create", 10);
assert!(results.len() >= 2);
let result_servers: Vec<_> = results.iter().map(|r| r.server.as_str()).collect();
assert!(result_servers.contains(&"redisctl"));
assert!(result_servers.contains(&"pgctl"));
let results = registry.query("PostgreSQL", 10);
assert!(!results.is_empty());
assert!(results.iter().all(|r| r.server == "pgctl"));
}
}