mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 07:20:19 +00:00
* refactor(registry): move MCP server entries from code to JSON manifests Move 8 hardcoded MCP server RegistryEntry structs from builtin_entries() into data-driven JSON files under registry/mcp-servers/, matching the existing pattern used by tools and channels. Exclude the GitHub MCP entry which conflicts with the WASM GitHub tool's OAuth flow. Extend ManifestKind with McpServer, make version/source optional on ExtensionManifest (MCP servers don't need them), and add url/auth fields for MCP-specific config. Update build.rs, embedded catalog, catalog loader, installer, and CLI display to handle the new kind and optional fields. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(registry): address PR review — add missing slack-mcp, remove .expect(), fix fmt - Add missing slack-mcp.json (was dropped during migration) - Remove production .expect() in get_strict(), replace with .ok_or_else() - Clean up unwrap_or_default() in key_for() to use .next() directly - Log warning for MCP manifests missing url field instead of silent empty - Run cargo fmt to fix formatting diffs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * ci: re-trigger CI with correct base branch (staging) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(ci): improve no-panics check to properly exclude test modules The grep-based filter only excluded lines literally containing #[cfg(test)], #[test], or 'mod tests' — not lines *inside* test modules. Use awk to track hunk context from diff @@ headers and skip all added lines within test module hunks. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(registry): remove slack-mcp MCP entry (conflicts with WASM slack tool) Remove slack-mcp.json alongside the already-excluded github MCP entry — both conflict with existing WASM tools of the same name. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(registry): address re-review — skip invalid MCP entries, fix install order - to_registry_entry() now returns Option<RegistryEntry>; MCP manifests missing a url field are skipped with a warning instead of creating broken entries with empty URLs - Move McpServer early-return before require_source() in install paths so the error message is clear ("cannot install MCP servers") rather than the misleading "missing source spec" - Add test for MCP manifest with missing URL returning None Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
848 lines
31 KiB
Rust
848 lines
31 KiB
Rust
//! Curated in-memory catalog of known extensions with fuzzy search.
|
|
//!
|
|
//! The registry holds well-known channels, tools, and MCP servers that can be
|
|
//! installed via conversational commands. Online discoveries are cached here too.
|
|
|
|
use tokio::sync::RwLock;
|
|
|
|
use crate::extensions::{
|
|
AuthHint, ExtensionKind, ExtensionSource, RegistryEntry, ResultSource, SearchResult,
|
|
};
|
|
|
|
/// Curated extension registry with fuzzy search.
|
|
pub struct ExtensionRegistry {
|
|
/// Built-in curated entries.
|
|
entries: Vec<RegistryEntry>,
|
|
/// Cached entries from online discovery (session-lived).
|
|
discovery_cache: RwLock<Vec<RegistryEntry>>,
|
|
}
|
|
|
|
impl ExtensionRegistry {
|
|
/// Create a new registry populated with known extensions.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
entries: builtin_entries(),
|
|
discovery_cache: RwLock::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// Create a new registry merging builtin entries with catalog-provided entries.
|
|
///
|
|
/// Deduplicates by `(name, kind)` pair -- a builtin MCP "slack" and a registry
|
|
/// WASM "slack" can coexist since they're different kinds.
|
|
pub fn new_with_catalog(catalog_entries: Vec<RegistryEntry>) -> Self {
|
|
let mut entries = builtin_entries();
|
|
for entry in catalog_entries {
|
|
if !entries
|
|
.iter()
|
|
.any(|e| e.name == entry.name && e.kind == entry.kind)
|
|
{
|
|
entries.push(entry);
|
|
}
|
|
}
|
|
Self {
|
|
entries,
|
|
discovery_cache: RwLock::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// Search the registry by query string. Returns results sorted by relevance.
|
|
///
|
|
/// Splits the query into lowercase tokens and scores each entry by matches
|
|
/// in name, keywords, and description.
|
|
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
|
let tokens: Vec<String> = query
|
|
.to_lowercase()
|
|
.split_whitespace()
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
|
|
if tokens.is_empty() {
|
|
// Return all entries when query is empty
|
|
return self
|
|
.entries
|
|
.iter()
|
|
.map(|e| SearchResult {
|
|
entry: e.clone(),
|
|
source: ResultSource::Registry,
|
|
validated: true,
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
let mut scored: Vec<(SearchResult, u32)> = Vec::new();
|
|
|
|
// Score built-in entries
|
|
for entry in &self.entries {
|
|
let score = score_entry(entry, &tokens);
|
|
if score > 0 {
|
|
scored.push((
|
|
SearchResult {
|
|
entry: entry.clone(),
|
|
source: ResultSource::Registry,
|
|
validated: true,
|
|
},
|
|
score,
|
|
));
|
|
}
|
|
}
|
|
|
|
// Score cached discoveries
|
|
let cache = self.discovery_cache.read().await;
|
|
for entry in cache.iter() {
|
|
let score = score_entry(entry, &tokens);
|
|
if score > 0 {
|
|
scored.push((
|
|
SearchResult {
|
|
entry: entry.clone(),
|
|
source: ResultSource::Discovered,
|
|
validated: true,
|
|
},
|
|
score,
|
|
));
|
|
}
|
|
}
|
|
|
|
scored.sort_by_key(|b| std::cmp::Reverse(b.1));
|
|
scored.into_iter().map(|(r, _)| r).collect()
|
|
}
|
|
|
|
/// Look up an entry by exact name.
|
|
///
|
|
/// NOTE: Prefer [`get_with_kind`] when a kind hint is available, to avoid
|
|
/// returning the wrong entry when two entries share a name but differ in kind.
|
|
pub async fn get(&self, name: &str) -> Option<RegistryEntry> {
|
|
if let Some(entry) = self.entries.iter().find(|e| e.name == name) {
|
|
return Some(entry.clone());
|
|
}
|
|
let cache = self.discovery_cache.read().await;
|
|
cache.iter().find(|e| e.name == name).cloned()
|
|
}
|
|
|
|
/// Look up an entry by exact name, filtering by kind when provided.
|
|
///
|
|
/// When `kind` is `Some(...)`, only returns an entry matching both name and
|
|
/// kind — never falls back to a different kind. When `kind` is `None`,
|
|
/// returns the first name match (same as [`get`]).
|
|
pub async fn get_with_kind(
|
|
&self,
|
|
name: &str,
|
|
kind: Option<ExtensionKind>,
|
|
) -> Option<RegistryEntry> {
|
|
if let Some(kind) = kind {
|
|
if let Some(entry) = self
|
|
.entries
|
|
.iter()
|
|
.find(|e| e.name == name && e.kind == kind)
|
|
{
|
|
return Some(entry.clone());
|
|
}
|
|
let cache = self.discovery_cache.read().await;
|
|
if let Some(entry) = cache.iter().find(|e| e.name == name && e.kind == kind) {
|
|
return Some(entry.clone());
|
|
}
|
|
// Kind was specified but no entry matches — don't fall back to a
|
|
// different kind, as that would silently misroute the install.
|
|
return None;
|
|
}
|
|
self.get(name).await
|
|
}
|
|
|
|
/// Return all registry entries (builtins + cached discoveries).
|
|
pub async fn all_entries(&self) -> Vec<RegistryEntry> {
|
|
let mut entries = self.entries.clone();
|
|
let cache = self.discovery_cache.read().await;
|
|
for entry in cache.iter() {
|
|
if !entries
|
|
.iter()
|
|
.any(|e| e.name == entry.name && e.kind == entry.kind)
|
|
{
|
|
entries.push(entry.clone());
|
|
}
|
|
}
|
|
entries
|
|
}
|
|
|
|
/// Add discovered entries to the cache.
|
|
pub async fn cache_discovered(&self, entries: Vec<RegistryEntry>) {
|
|
let mut cache = self.discovery_cache.write().await;
|
|
for entry in entries {
|
|
// Deduplicate by (name, kind) — same pair as new_with_catalog()
|
|
if !cache
|
|
.iter()
|
|
.any(|e| e.name == entry.name && e.kind == entry.kind)
|
|
{
|
|
cache.push(entry);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for ExtensionRegistry {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Score an entry against search tokens. Higher = better match.
|
|
fn score_entry(entry: &RegistryEntry, tokens: &[String]) -> u32 {
|
|
let mut score = 0u32;
|
|
let name_lower = entry.name.to_lowercase();
|
|
let display_lower = entry.display_name.to_lowercase();
|
|
let desc_lower = entry.description.to_lowercase();
|
|
let keywords_lower: Vec<String> = entry.keywords.iter().map(|k| k.to_lowercase()).collect();
|
|
|
|
for token in tokens {
|
|
// Exact name match is the strongest signal
|
|
if name_lower == *token {
|
|
score += 100;
|
|
} else if name_lower.contains(token.as_str()) {
|
|
score += 50;
|
|
}
|
|
|
|
// Display name match
|
|
if display_lower.contains(token.as_str()) {
|
|
score += 30;
|
|
}
|
|
|
|
// Keyword match
|
|
for kw in &keywords_lower {
|
|
if kw == token {
|
|
score += 40;
|
|
} else if kw.contains(token.as_str()) {
|
|
score += 20;
|
|
}
|
|
}
|
|
|
|
// Description match (weakest signal)
|
|
if desc_lower.contains(token.as_str()) {
|
|
score += 10;
|
|
}
|
|
}
|
|
|
|
score
|
|
}
|
|
|
|
/// Well-known extensions that ship with ironclaw.
|
|
///
|
|
/// If `relay_url` is provided, a channel-relay Slack entry is included in the list.
|
|
/// Pass `None` when the relay is not configured.
|
|
pub fn builtin_entries() -> Vec<RegistryEntry> {
|
|
builtin_entries_with_relay(std::env::var("CHANNEL_RELAY_URL").ok())
|
|
}
|
|
|
|
/// Well-known extensions, with an optional relay URL for the channel-relay entry.
|
|
///
|
|
/// MCP server entries are loaded from `registry/mcp-servers/*.json` via the catalog
|
|
/// system. Only runtime-dependent entries (like channel-relay) remain here.
|
|
pub fn builtin_entries_with_relay(relay_url: Option<String>) -> Vec<RegistryEntry> {
|
|
let mut entries = vec![];
|
|
|
|
// Conditionally add channel-relay entries when relay URL is configured
|
|
if let Some(relay_url) = relay_url {
|
|
entries.push(RegistryEntry {
|
|
name: crate::channels::relay::DEFAULT_RELAY_NAME.to_string(),
|
|
display_name: "Slack".to_string(),
|
|
kind: ExtensionKind::ChannelRelay,
|
|
description: "Connect Slack workspace via channel relay".to_string(),
|
|
keywords: vec![
|
|
"slack".into(),
|
|
"chat".into(),
|
|
"messaging".into(),
|
|
"relay".into(),
|
|
],
|
|
source: ExtensionSource::ChannelRelay { relay_url },
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::ChannelRelayOAuth,
|
|
version: None,
|
|
});
|
|
}
|
|
|
|
entries
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::extensions::registry::{ExtensionRegistry, score_entry};
|
|
use crate::extensions::{AuthHint, ExtensionKind, ExtensionSource, RegistryEntry};
|
|
|
|
#[test]
|
|
fn test_score_exact_name_match() {
|
|
let entry = RegistryEntry {
|
|
name: "notion".to_string(),
|
|
display_name: "Notion".to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
description: "Workspace tool".to_string(),
|
|
keywords: vec!["notes".into()],
|
|
source: ExtensionSource::McpUrl {
|
|
url: "https://example.com".to_string(),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::Dcr,
|
|
version: None,
|
|
};
|
|
|
|
let score = score_entry(&entry, &["notion".to_string()]);
|
|
assert!(
|
|
score >= 100,
|
|
"Exact name match should score >= 100, got {}",
|
|
score
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_score_partial_name_match() {
|
|
let entry = RegistryEntry {
|
|
name: "google-calendar".to_string(),
|
|
display_name: "Google Calendar".to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
description: "Calendar management".to_string(),
|
|
keywords: vec!["events".into()],
|
|
source: ExtensionSource::McpUrl {
|
|
url: "https://example.com".to_string(),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::Dcr,
|
|
version: None,
|
|
};
|
|
|
|
let score = score_entry(&entry, &["calendar".to_string()]);
|
|
assert!(
|
|
score > 0,
|
|
"Partial name match should score > 0, got {}",
|
|
score
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_score_keyword_match() {
|
|
let entry = RegistryEntry {
|
|
name: "notion".to_string(),
|
|
display_name: "Notion".to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
description: "Workspace tool".to_string(),
|
|
keywords: vec!["wiki".into(), "notes".into()],
|
|
source: ExtensionSource::McpUrl {
|
|
url: "https://example.com".to_string(),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::Dcr,
|
|
version: None,
|
|
};
|
|
|
|
let score = score_entry(&entry, &["wiki".to_string()]);
|
|
assert!(
|
|
score >= 40,
|
|
"Exact keyword match should score >= 40, got {}",
|
|
score
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_score_no_match() {
|
|
let entry = RegistryEntry {
|
|
name: "notion".to_string(),
|
|
display_name: "Notion".to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
description: "Workspace tool".to_string(),
|
|
keywords: vec!["notes".into()],
|
|
source: ExtensionSource::McpUrl {
|
|
url: "https://example.com".to_string(),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::Dcr,
|
|
version: None,
|
|
};
|
|
|
|
let score = score_entry(&entry, &["xyzfoobar".to_string()]);
|
|
assert_eq!(score, 0, "No match should score 0");
|
|
}
|
|
|
|
/// Helper to create a registry with catalog entries (MCP servers come from catalog now).
|
|
fn registry_with_catalog() -> ExtensionRegistry {
|
|
let catalog = crate::registry::catalog::RegistryCatalog::load_or_embedded()
|
|
.expect("catalog should load");
|
|
let catalog_entries: Vec<RegistryEntry> = catalog
|
|
.all()
|
|
.iter()
|
|
.filter_map(|m| m.to_registry_entry())
|
|
.collect();
|
|
ExtensionRegistry::new_with_catalog(catalog_entries)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_search_returns_sorted() {
|
|
let registry = registry_with_catalog();
|
|
let results = registry.search("notion").await;
|
|
|
|
assert!(!results.is_empty(), "Should find notion in registry");
|
|
assert_eq!(results[0].entry.name, "notion");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_search_empty_query_returns_all() {
|
|
let registry = registry_with_catalog();
|
|
let results = registry.search("").await;
|
|
|
|
assert!(results.len() > 5, "Empty query should return all entries");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_search_by_keyword() {
|
|
let registry = registry_with_catalog();
|
|
let results = registry.search("issues tickets").await;
|
|
|
|
assert!(
|
|
!results.is_empty(),
|
|
"Should find entries matching 'issues tickets'"
|
|
);
|
|
// Linear should be near the top since it has both keywords
|
|
let linear_pos = results.iter().position(|r| r.entry.name == "linear");
|
|
assert!(linear_pos.is_some(), "Linear should appear in results");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_exact_name() {
|
|
let registry = registry_with_catalog();
|
|
|
|
let entry = registry.get("notion").await;
|
|
assert!(entry.is_some());
|
|
assert_eq!(entry.unwrap().display_name, "Notion");
|
|
|
|
let missing = registry.get("nonexistent").await;
|
|
assert!(missing.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cache_discovered() {
|
|
let registry = ExtensionRegistry::new();
|
|
|
|
let discovered = RegistryEntry {
|
|
name: "custom-mcp".to_string(),
|
|
display_name: "Custom MCP".to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
description: "A custom MCP server".to_string(),
|
|
keywords: vec![],
|
|
source: ExtensionSource::McpUrl {
|
|
url: "https://custom.example.com".to_string(),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::Dcr,
|
|
version: None,
|
|
};
|
|
|
|
registry.cache_discovered(vec![discovered]).await;
|
|
|
|
let entry = registry.get("custom-mcp").await;
|
|
assert!(entry.is_some());
|
|
|
|
let results = registry.search("custom").await;
|
|
assert!(!results.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cache_deduplication() {
|
|
let registry = ExtensionRegistry::new();
|
|
|
|
let entry = RegistryEntry {
|
|
name: "dup".to_string(),
|
|
display_name: "Dup".to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
description: "Test".to_string(),
|
|
keywords: vec![],
|
|
source: ExtensionSource::McpUrl {
|
|
url: "https://example.com".to_string(),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::None,
|
|
version: None,
|
|
};
|
|
|
|
registry.cache_discovered(vec![entry.clone()]).await;
|
|
registry.cache_discovered(vec![entry]).await;
|
|
|
|
let results = registry.search("dup").await;
|
|
assert_eq!(results.len(), 1, "Should not duplicate cached entries");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_new_with_catalog() {
|
|
let catalog_entries = vec![
|
|
RegistryEntry {
|
|
name: "telegram".to_string(),
|
|
display_name: "Telegram".to_string(),
|
|
kind: ExtensionKind::WasmChannel,
|
|
description: "Telegram Bot API channel".to_string(),
|
|
keywords: vec!["messaging".into(), "bot".into()],
|
|
source: ExtensionSource::WasmBuildable {
|
|
source_dir: "channels-src/telegram".to_string(),
|
|
build_dir: Some("channels-src/telegram".to_string()),
|
|
crate_name: Some("telegram-channel".to_string()),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::CapabilitiesAuth,
|
|
version: None,
|
|
},
|
|
// Two entries with same name but different kinds should coexist
|
|
RegistryEntry {
|
|
name: "dual-ext".to_string(),
|
|
display_name: "Dual MCP".to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
description: "Dual extension MCP server".to_string(),
|
|
keywords: vec!["messaging".into()],
|
|
source: ExtensionSource::McpUrl {
|
|
url: "https://mcp.example.com".to_string(),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::Dcr,
|
|
version: None,
|
|
},
|
|
RegistryEntry {
|
|
name: "dual-ext".to_string(),
|
|
display_name: "Dual WASM".to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
description: "Dual extension WASM tool".to_string(),
|
|
keywords: vec!["messaging".into()],
|
|
source: ExtensionSource::WasmBuildable {
|
|
source_dir: "tools-src/dual".to_string(),
|
|
build_dir: Some("tools-src/dual".to_string()),
|
|
crate_name: Some("dual-tool".to_string()),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::CapabilitiesAuth,
|
|
version: None,
|
|
},
|
|
];
|
|
|
|
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
|
|
|
|
// Should find the new telegram entry
|
|
let results = registry.search("telegram").await;
|
|
assert!(!results.is_empty(), "Should find telegram from catalog");
|
|
assert_eq!(results[0].entry.name, "telegram");
|
|
|
|
// Should have both MCP and WASM entries with the same name
|
|
let results = registry.search("dual-ext").await;
|
|
let has_mcp = results
|
|
.iter()
|
|
.any(|r| r.entry.name == "dual-ext" && r.entry.kind == ExtensionKind::McpServer);
|
|
let has_wasm = results
|
|
.iter()
|
|
.any(|r| r.entry.name == "dual-ext" && r.entry.kind == ExtensionKind::WasmTool);
|
|
assert!(has_mcp, "Should have MCP dual-ext");
|
|
assert!(has_wasm, "Should have WASM dual-ext");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_new_with_catalog_dedup_same_kind() {
|
|
// When two catalog entries share name AND kind, only the first should be kept
|
|
let catalog_entries = vec![
|
|
RegistryEntry {
|
|
name: "test-ext".to_string(),
|
|
display_name: "Test First".to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
description: "First entry".to_string(),
|
|
keywords: vec![],
|
|
source: ExtensionSource::McpUrl {
|
|
url: "https://first.example.com".to_string(),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::Dcr,
|
|
version: None,
|
|
},
|
|
RegistryEntry {
|
|
name: "test-ext".to_string(),
|
|
display_name: "Test Duplicate".to_string(),
|
|
kind: ExtensionKind::McpServer, // same kind
|
|
description: "Should be skipped".to_string(),
|
|
keywords: vec![],
|
|
source: ExtensionSource::McpUrl {
|
|
url: "https://second.example.com".to_string(),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::Dcr,
|
|
version: None,
|
|
},
|
|
];
|
|
|
|
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
|
|
|
|
let entry = registry.get("test-ext").await;
|
|
assert!(entry.is_some());
|
|
// Should be the first entry, not the duplicate
|
|
assert_eq!(entry.unwrap().display_name, "Test First");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_with_kind_resolves_collision() {
|
|
// Two entries with the same name but different kinds (the telegram collision scenario)
|
|
let catalog_entries = vec![
|
|
RegistryEntry {
|
|
name: "telegram".to_string(),
|
|
display_name: "Telegram Tool".to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
description: "Telegram MTProto tool".to_string(),
|
|
keywords: vec!["messaging".into()],
|
|
source: ExtensionSource::WasmBuildable {
|
|
source_dir: "tools-src/telegram".to_string(),
|
|
build_dir: Some("tools-src/telegram".to_string()),
|
|
crate_name: Some("telegram-tool".to_string()),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::CapabilitiesAuth,
|
|
version: None,
|
|
},
|
|
RegistryEntry {
|
|
name: "telegram".to_string(),
|
|
display_name: "Telegram Channel".to_string(),
|
|
kind: ExtensionKind::WasmChannel,
|
|
description: "Telegram Bot API channel".to_string(),
|
|
keywords: vec!["messaging".into(), "bot".into()],
|
|
source: ExtensionSource::WasmBuildable {
|
|
source_dir: "channels-src/telegram".to_string(),
|
|
build_dir: Some("channels-src/telegram".to_string()),
|
|
crate_name: Some("telegram-channel".to_string()),
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::CapabilitiesAuth,
|
|
version: None,
|
|
},
|
|
];
|
|
|
|
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
|
|
|
|
// Without kind hint, get() returns the first match (WasmTool)
|
|
let entry = registry.get("telegram").await;
|
|
assert!(entry.is_some());
|
|
assert_eq!(entry.unwrap().kind, ExtensionKind::WasmTool);
|
|
|
|
// With kind hint for WasmChannel, get_with_kind() returns the channel entry
|
|
let entry = registry
|
|
.get_with_kind("telegram", Some(ExtensionKind::WasmChannel))
|
|
.await;
|
|
assert!(entry.is_some());
|
|
let entry = entry.unwrap();
|
|
assert_eq!(entry.kind, ExtensionKind::WasmChannel);
|
|
assert_eq!(entry.display_name, "Telegram Channel");
|
|
|
|
// With kind hint for WasmTool, get_with_kind() returns the tool entry
|
|
let entry = registry
|
|
.get_with_kind("telegram", Some(ExtensionKind::WasmTool))
|
|
.await;
|
|
assert!(entry.is_some());
|
|
let entry = entry.unwrap();
|
|
assert_eq!(entry.kind, ExtensionKind::WasmTool);
|
|
assert_eq!(entry.display_name, "Telegram Tool");
|
|
|
|
// Without kind hint (None), get_with_kind() falls back to first match
|
|
let entry = registry.get_with_kind("telegram", None).await;
|
|
assert!(entry.is_some());
|
|
assert_eq!(entry.unwrap().kind, ExtensionKind::WasmTool);
|
|
|
|
// Kind mismatch: no McpServer named "telegram" exists — must return None,
|
|
// not silently fall back to the WasmTool entry.
|
|
let entry = registry
|
|
.get_with_kind("telegram", Some(ExtensionKind::McpServer))
|
|
.await;
|
|
assert!(
|
|
entry.is_none(),
|
|
"Should return None when kind doesn't match, not fall back to wrong kind"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_with_kind_discovery_cache() {
|
|
let registry = ExtensionRegistry::new();
|
|
|
|
// Add two entries with the same name but different kinds to the discovery cache
|
|
let tool_entry = RegistryEntry {
|
|
name: "cached-ext".to_string(),
|
|
display_name: "Cached Tool".to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
description: "A cached tool".to_string(),
|
|
keywords: vec![],
|
|
source: ExtensionSource::WasmBuildable {
|
|
source_dir: "tools-src/cached".to_string(),
|
|
build_dir: None,
|
|
crate_name: None,
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::None,
|
|
version: None,
|
|
};
|
|
let channel_entry = RegistryEntry {
|
|
name: "cached-ext".to_string(),
|
|
display_name: "Cached Channel".to_string(),
|
|
kind: ExtensionKind::WasmChannel,
|
|
description: "A cached channel".to_string(),
|
|
keywords: vec![],
|
|
source: ExtensionSource::WasmBuildable {
|
|
source_dir: "channels-src/cached".to_string(),
|
|
build_dir: None,
|
|
crate_name: None,
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::None,
|
|
version: None,
|
|
};
|
|
|
|
registry
|
|
.cache_discovered(vec![tool_entry, channel_entry])
|
|
.await;
|
|
|
|
// Kind-aware lookup should find the channel in the cache
|
|
let entry = registry
|
|
.get_with_kind("cached-ext", Some(ExtensionKind::WasmChannel))
|
|
.await;
|
|
assert!(entry.is_some());
|
|
assert_eq!(entry.unwrap().display_name, "Cached Channel");
|
|
|
|
// Kind-aware lookup should find the tool in the cache
|
|
let entry = registry
|
|
.get_with_kind("cached-ext", Some(ExtensionKind::WasmTool))
|
|
.await;
|
|
assert!(entry.is_some());
|
|
assert_eq!(entry.unwrap().display_name, "Cached Tool");
|
|
}
|
|
|
|
// Channel tests (telegram, slack, discord, whatsapp) require the embedded catalog
|
|
// to be loaded via new_with_catalog(). See test_new_with_catalog for catalog coverage.
|
|
|
|
// === QA Plan P2 - 2.4: Extension registry collision tests ===
|
|
|
|
#[tokio::test]
|
|
async fn test_same_name_different_kind_both_discoverable() {
|
|
// A WASM channel and WASM tool with the same name must coexist.
|
|
let catalog_entries = vec![
|
|
RegistryEntry {
|
|
name: "telegram".to_string(),
|
|
display_name: "Telegram Channel".to_string(),
|
|
kind: ExtensionKind::WasmChannel,
|
|
description: "Telegram messaging channel".to_string(),
|
|
keywords: vec!["messaging".into()],
|
|
source: ExtensionSource::WasmBuildable {
|
|
source_dir: "channels-src/telegram".to_string(),
|
|
build_dir: None,
|
|
crate_name: None,
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::CapabilitiesAuth,
|
|
version: None,
|
|
},
|
|
RegistryEntry {
|
|
name: "telegram".to_string(),
|
|
display_name: "Telegram Tool".to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
description: "Telegram API tool".to_string(),
|
|
keywords: vec!["messaging".into()],
|
|
source: ExtensionSource::WasmBuildable {
|
|
source_dir: "tools-src/telegram".to_string(),
|
|
build_dir: None,
|
|
crate_name: None,
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::CapabilitiesAuth,
|
|
version: None,
|
|
},
|
|
];
|
|
|
|
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
|
|
let all = registry.all_entries().await;
|
|
|
|
// Both should exist since they have different kinds.
|
|
let channel = all
|
|
.iter()
|
|
.find(|e| e.name == "telegram" && e.kind == ExtensionKind::WasmChannel);
|
|
let tool = all
|
|
.iter()
|
|
.find(|e| e.name == "telegram" && e.kind == ExtensionKind::WasmTool);
|
|
|
|
assert!(channel.is_some(), "Channel entry missing");
|
|
assert!(tool.is_some(), "Tool entry missing");
|
|
|
|
// Search should return both.
|
|
let results = registry.search("telegram").await;
|
|
let channel_hit = results
|
|
.iter()
|
|
.any(|r| r.entry.name == "telegram" && r.entry.kind == ExtensionKind::WasmChannel);
|
|
let tool_hit = results
|
|
.iter()
|
|
.any(|r| r.entry.name == "telegram" && r.entry.kind == ExtensionKind::WasmTool);
|
|
assert!(channel_hit, "Search should find channel");
|
|
assert!(tool_hit, "Search should find tool");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_returns_first_match_regardless_of_kind() {
|
|
// `get()` returns the first entry with a matching name. If a channel
|
|
// and tool share a name, callers that need a specific kind should
|
|
// filter by kind.
|
|
let catalog_entries = vec![
|
|
RegistryEntry {
|
|
name: "myext".to_string(),
|
|
display_name: "MyExt Channel".to_string(),
|
|
kind: ExtensionKind::WasmChannel,
|
|
description: "Channel".to_string(),
|
|
keywords: vec![],
|
|
source: ExtensionSource::WasmBuildable {
|
|
source_dir: "x".to_string(),
|
|
build_dir: None,
|
|
crate_name: None,
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::None,
|
|
version: None,
|
|
},
|
|
RegistryEntry {
|
|
name: "myext".to_string(),
|
|
display_name: "MyExt Tool".to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
description: "Tool".to_string(),
|
|
keywords: vec![],
|
|
source: ExtensionSource::WasmBuildable {
|
|
source_dir: "y".to_string(),
|
|
build_dir: None,
|
|
crate_name: None,
|
|
},
|
|
fallback_source: None,
|
|
auth_hint: AuthHint::None,
|
|
version: None,
|
|
},
|
|
];
|
|
|
|
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
|
|
|
|
// get() is name-only, returns first match.
|
|
let entry = registry.get("myext").await;
|
|
assert!(entry.is_some());
|
|
// The first catalog entry added is the channel.
|
|
assert_eq!(entry.unwrap().kind, ExtensionKind::WasmChannel);
|
|
}
|
|
|
|
#[test]
|
|
fn test_builtin_entries_with_relay_none_excludes_relay() {
|
|
let entries = super::builtin_entries_with_relay(None);
|
|
assert!(
|
|
!entries
|
|
.iter()
|
|
.any(|e| e.kind == ExtensionKind::ChannelRelay),
|
|
"No ChannelRelay entry when relay URL is None"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_builtin_entries_with_relay_some_includes_relay() {
|
|
let entries =
|
|
super::builtin_entries_with_relay(Some("http://relay.example.com".to_string()));
|
|
let relay = entries
|
|
.iter()
|
|
.find(|e| e.kind == ExtensionKind::ChannelRelay);
|
|
assert!(relay.is_some(), "ChannelRelay entry should be present");
|
|
if let ExtensionSource::ChannelRelay { relay_url } = &relay.unwrap().source {
|
|
assert_eq!(relay_url, "http://relay.example.com");
|
|
} else {
|
|
panic!("Expected ChannelRelay source");
|
|
}
|
|
}
|
|
}
|