mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 07:20:19 +00:00
refactor(registry): move MCP servers from code to JSON manifests (#1144)
* 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]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
757d24bd90
commit
c916069dd2
@@ -89,19 +89,34 @@ jobs:
|
||||
- name: Check for .unwrap(), .expect(), assert!() in production code
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
# Get added lines in .rs files (production only, exclude tests/)
|
||||
ADDED=$(git diff "$BASE"...HEAD -- 'src/**/*.rs' 'crates/**/*.rs' \
|
||||
| grep -E '^\+[^+]' || true)
|
||||
# Get the full diff for .rs files (production only, exclude tests/ directory)
|
||||
DIFF=$(git diff "$BASE"...HEAD -- 'src/**/*.rs' 'crates/**/*.rs' || true)
|
||||
|
||||
if [ -z "$ADDED" ]; then
|
||||
if [ -z "$DIFF" ]; then
|
||||
echo "No production Rust changes detected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Match panic-inducing patterns, excluding test code and safety suppressions
|
||||
# Extract added lines, skipping those inside test modules.
|
||||
# Track whether we're inside a test module by watching hunk headers
|
||||
# (lines starting with @@) whose context contains "mod tests" or "#[cfg(test)]".
|
||||
ADDED=$(echo "$DIFF" | awk '
|
||||
/^@@/ {
|
||||
# Hunk context (after the second @@) tells us the function/module scope
|
||||
in_test = (tolower($0) ~ /mod tests/ || $0 ~ /#\[cfg\(test\)\]/ || $0 ~ /#\[test\]/)
|
||||
}
|
||||
/^\+[^+]/ && !in_test { print }
|
||||
' || true)
|
||||
|
||||
if [ -z "$ADDED" ]; then
|
||||
echo "No production Rust changes detected (test-only changes excluded)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Match panic-inducing patterns, excluding safety suppressions
|
||||
VIOLATIONS=$(echo "$ADDED" \
|
||||
| grep -E '\.(unwrap|expect)\(|[^_]assert(_eq|_ne)?!' \
|
||||
| grep -Ev 'debug_assert|// safety:|#\[cfg\(test\)\]|#\[test\]|mod tests' \
|
||||
| grep -Ev 'debug_assert|// safety:' \
|
||||
|| true)
|
||||
|
||||
if [ -n "$VIOLATIONS" ]; then
|
||||
|
||||
@@ -132,7 +132,7 @@ fn embed_registry_catalog(root: &Path) {
|
||||
// No registry dir: write empty catalog
|
||||
fs::write(
|
||||
&out_path,
|
||||
r#"{"tools":[],"channels":[],"bundles":{"bundles":{}}}"#,
|
||||
r#"{"tools":[],"channels":[],"mcp_servers":[],"bundles":{"bundles":{}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
@@ -140,6 +140,7 @@ fn embed_registry_catalog(root: &Path) {
|
||||
|
||||
let mut tools = Vec::new();
|
||||
let mut channels = Vec::new();
|
||||
let mut mcp_servers = Vec::new();
|
||||
|
||||
// Collect tool manifests
|
||||
let tools_dir = registry_dir.join("tools");
|
||||
@@ -153,6 +154,12 @@ fn embed_registry_catalog(root: &Path) {
|
||||
collect_json_files(&channels_dir, &mut channels);
|
||||
}
|
||||
|
||||
// Collect MCP server manifests
|
||||
let mcp_servers_dir = registry_dir.join("mcp-servers");
|
||||
if mcp_servers_dir.is_dir() {
|
||||
collect_json_files(&mcp_servers_dir, &mut mcp_servers);
|
||||
}
|
||||
|
||||
// Read bundles
|
||||
let bundles_path = registry_dir.join("_bundles.json");
|
||||
let bundles_raw = if bundles_path.is_file() {
|
||||
@@ -163,9 +170,10 @@ fn embed_registry_catalog(root: &Path) {
|
||||
|
||||
// Build the combined JSON
|
||||
let catalog = format!(
|
||||
r#"{{"tools":[{}],"channels":[{}],"bundles":{}}}"#,
|
||||
r#"{{"tools":[{}],"channels":[{}],"mcp_servers":[{}],"bundles":{}}}"#,
|
||||
tools.join(","),
|
||||
channels.join(","),
|
||||
mcp_servers.join(","),
|
||||
bundles_raw,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "asana",
|
||||
"display_name": "Asana",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Asana for task management, projects, and team coordination",
|
||||
"keywords": ["tasks", "projects", "management", "team"],
|
||||
"url": "https://mcp.asana.com/v2/mcp",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "cloudflare",
|
||||
"display_name": "Cloudflare",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Cloudflare for DNS, Workers, KV, and infrastructure management",
|
||||
"keywords": ["cdn", "dns", "workers", "hosting", "infrastructure"],
|
||||
"url": "https://mcp.cloudflare.com/mcp",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "intercom",
|
||||
"display_name": "Intercom",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Intercom for customer messaging, support, and engagement",
|
||||
"keywords": ["support", "customers", "messaging", "chat", "helpdesk"],
|
||||
"url": "https://mcp.intercom.com/mcp",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "linear",
|
||||
"display_name": "Linear",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Linear for issue tracking, project management, and team workflows",
|
||||
"keywords": ["issues", "tickets", "project", "tracking", "bugs"],
|
||||
"url": "https://mcp.linear.app/sse",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "notion",
|
||||
"display_name": "Notion",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Notion for reading and writing pages, databases, and comments",
|
||||
"keywords": ["notes", "wiki", "docs", "pages", "database"],
|
||||
"url": "https://mcp.notion.com/mcp",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "sentry",
|
||||
"display_name": "Sentry",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Sentry for error tracking, performance monitoring, and debugging",
|
||||
"keywords": ["errors", "monitoring", "debugging", "crashes", "performance"],
|
||||
"url": "https://mcp.sentry.dev/mcp",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "stripe",
|
||||
"display_name": "Stripe",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Stripe for payment processing, subscriptions, and financial data",
|
||||
"keywords": ["payments", "billing", "subscriptions", "invoices", "finance"],
|
||||
"url": "https://mcp.stripe.com",
|
||||
"auth": "dcr"
|
||||
}
|
||||
+1
-1
@@ -594,7 +594,7 @@ impl AppBuilder {
|
||||
let entries: Vec<_> = catalog
|
||||
.all()
|
||||
.iter()
|
||||
.map(|m| m.to_registry_entry())
|
||||
.filter_map(|m| m.to_registry_entry())
|
||||
.collect();
|
||||
tracing::debug!(
|
||||
count = entries.len(),
|
||||
|
||||
+17
-5
@@ -127,7 +127,11 @@ fn cmd_list(
|
||||
.unwrap_or("none");
|
||||
println!(
|
||||
"{:<20} {:<8} {:<8} {:<10} {}",
|
||||
m.name, m.kind, m.version, auth, m.description
|
||||
m.name,
|
||||
m.kind,
|
||||
m.version.as_deref().unwrap_or("-"),
|
||||
auth,
|
||||
m.description
|
||||
);
|
||||
} else {
|
||||
println!("{:<20} {:<8} {}", m.name, m.kind, m.description);
|
||||
@@ -173,17 +177,25 @@ fn cmd_info(catalog: &RegistryCatalog, name: &str) -> anyhow::Result<()> {
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("{} ({})", manifest.display_name, manifest.kind);
|
||||
println!(" Version: {}", manifest.version);
|
||||
if let Some(ref version) = manifest.version {
|
||||
println!(" Version: {}", version);
|
||||
}
|
||||
println!(" {}", manifest.description);
|
||||
|
||||
if !manifest.keywords.is_empty() {
|
||||
println!(" Keywords: {}", manifest.keywords.join(", "));
|
||||
}
|
||||
|
||||
if let Some(ref source) = manifest.source {
|
||||
println!("\nSource:");
|
||||
println!(" Directory: {}", manifest.source.dir);
|
||||
println!(" Crate: {}", manifest.source.crate_name);
|
||||
println!(" Capabilities: {}", manifest.source.capabilities);
|
||||
println!(" Directory: {}", source.dir);
|
||||
println!(" Crate: {}", source.crate_name);
|
||||
println!(" Capabilities: {}", source.capabilities);
|
||||
}
|
||||
|
||||
if let Some(ref url) = manifest.url {
|
||||
println!("\nMCP Server URL: {}", url);
|
||||
}
|
||||
|
||||
if let Some(artifact) = manifest.artifacts.get("wasm32-wasip2") {
|
||||
println!("\nArtifact (wasm32-wasip2):");
|
||||
|
||||
+74
-221
@@ -232,198 +232,11 @@ pub fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
}
|
||||
|
||||
/// 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![
|
||||
// -- MCP Servers --
|
||||
RegistryEntry {
|
||||
name: "notion".to_string(),
|
||||
display_name: "Notion".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: "Connect to Notion for reading and writing pages, databases, and comments"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"notes".into(),
|
||||
"wiki".into(),
|
||||
"docs".into(),
|
||||
"pages".into(),
|
||||
"database".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.notion.com/mcp".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "linear".to_string(),
|
||||
display_name: "Linear".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to Linear for issue tracking, project management, and team workflows"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"issues".into(),
|
||||
"tickets".into(),
|
||||
"project".into(),
|
||||
"tracking".into(),
|
||||
"bugs".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.linear.app/sse".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "github".to_string(),
|
||||
display_name: "GitHub".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to GitHub for repository management, issues, PRs, and code search"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"git".into(),
|
||||
"repos".into(),
|
||||
"code".into(),
|
||||
"pull-request".into(),
|
||||
"issues".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://api.githubcopilot.com/mcp/".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "slack-mcp".to_string(),
|
||||
display_name: "Slack MCP".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to Slack via MCP for messaging, channel management, and team communication"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"messaging".into(),
|
||||
"chat".into(),
|
||||
"channels".into(),
|
||||
"team".into(),
|
||||
"communication".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.slack.com".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "sentry".to_string(),
|
||||
display_name: "Sentry".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to Sentry for error tracking, performance monitoring, and debugging"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"errors".into(),
|
||||
"monitoring".into(),
|
||||
"debugging".into(),
|
||||
"crashes".into(),
|
||||
"performance".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.sentry.dev/mcp".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "stripe".to_string(),
|
||||
display_name: "Stripe".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to Stripe for payment processing, subscriptions, and financial data"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"payments".into(),
|
||||
"billing".into(),
|
||||
"subscriptions".into(),
|
||||
"invoices".into(),
|
||||
"finance".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.stripe.com".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "cloudflare".to_string(),
|
||||
display_name: "Cloudflare".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to Cloudflare for DNS, Workers, KV, and infrastructure management"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"cdn".into(),
|
||||
"dns".into(),
|
||||
"workers".into(),
|
||||
"hosting".into(),
|
||||
"infrastructure".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.cloudflare.com/mcp".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "asana".to_string(),
|
||||
display_name: "Asana".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: "Connect to Asana for task management, projects, and team coordination"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"tasks".into(),
|
||||
"projects".into(),
|
||||
"management".into(),
|
||||
"team".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.asana.com/v2/mcp".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "intercom".to_string(),
|
||||
display_name: "Intercom".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: "Connect to Intercom for customer messaging, support, and engagement"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"support".into(),
|
||||
"customers".into(),
|
||||
"messaging".into(),
|
||||
"chat".into(),
|
||||
"helpdesk".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.intercom.com/mcp".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
// WASM channels (telegram, slack, discord, whatsapp) come from the embedded
|
||||
// registry catalog (registry/channels/*.json) with WasmDownload URLs pointing
|
||||
// to GitHub release artifacts. See new_with_catalog() for merging.
|
||||
];
|
||||
let mut entries = vec![];
|
||||
|
||||
// Conditionally add channel-relay entries when relay URL is configured
|
||||
if let Some(relay_url) = relay_url {
|
||||
@@ -545,9 +358,21 @@ mod tests {
|
||||
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 = ExtensionRegistry::new();
|
||||
let registry = registry_with_catalog();
|
||||
let results = registry.search("notion").await;
|
||||
|
||||
assert!(!results.is_empty(), "Should find notion in registry");
|
||||
@@ -556,7 +381,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_empty_query_returns_all() {
|
||||
let registry = ExtensionRegistry::new();
|
||||
let registry = registry_with_catalog();
|
||||
let results = registry.search("").await;
|
||||
|
||||
assert!(results.len() > 5, "Empty query should return all entries");
|
||||
@@ -564,7 +389,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_by_keyword() {
|
||||
let registry = ExtensionRegistry::new();
|
||||
let registry = registry_with_catalog();
|
||||
let results = registry.search("issues tickets").await;
|
||||
|
||||
assert!(
|
||||
@@ -578,7 +403,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_exact_name() {
|
||||
let registry = ExtensionRegistry::new();
|
||||
let registry = registry_with_catalog();
|
||||
|
||||
let entry = registry.get("notion").await;
|
||||
assert!(entry.is_some());
|
||||
@@ -658,17 +483,30 @@ mod tests {
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
version: None,
|
||||
},
|
||||
// This shares a name with the builtin slack-mcp but has a different kind, so both should appear
|
||||
// Two entries with same name but different kinds should coexist
|
||||
RegistryEntry {
|
||||
name: "slack-mcp".to_string(),
|
||||
display_name: "Slack MCP WASM".to_string(),
|
||||
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: "Slack WASM tool".to_string(),
|
||||
description: "Dual extension WASM tool".to_string(),
|
||||
keywords: vec!["messaging".into()],
|
||||
source: ExtensionSource::WasmBuildable {
|
||||
source_dir: "tools-src/slack".to_string(),
|
||||
build_dir: Some("tools-src/slack".to_string()),
|
||||
crate_name: Some("slack-tool".to_string()),
|
||||
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,
|
||||
@@ -683,41 +521,56 @@ mod tests {
|
||||
assert!(!results.is_empty(), "Should find telegram from catalog");
|
||||
assert_eq!(results[0].entry.name, "telegram");
|
||||
|
||||
// Should have both builtin MCP slack-mcp and catalog WASM slack-mcp
|
||||
let results = registry.search("slack").await;
|
||||
let slack_mcp = results
|
||||
// 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 == "slack-mcp" && r.entry.kind == ExtensionKind::McpServer);
|
||||
let slack_wasm = results
|
||||
.any(|r| r.entry.name == "dual-ext" && r.entry.kind == ExtensionKind::McpServer);
|
||||
let has_wasm = results
|
||||
.iter()
|
||||
.any(|r| r.entry.name == "slack-mcp" && r.entry.kind == ExtensionKind::WasmTool);
|
||||
assert!(slack_mcp, "Should have builtin MCP slack-mcp");
|
||||
assert!(slack_wasm, "Should have catalog WASM slack-mcp");
|
||||
.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() {
|
||||
// A catalog entry with same name AND kind as a builtin should be skipped
|
||||
let catalog_entries = vec![RegistryEntry {
|
||||
name: "slack-mcp".to_string(),
|
||||
display_name: "Slack MCP Override".to_string(),
|
||||
kind: ExtensionKind::McpServer, // same kind as builtin slack-mcp
|
||||
description: "Should be skipped".to_string(),
|
||||
// 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://other.slack.com".to_string(),
|
||||
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("slack-mcp").await;
|
||||
let entry = registry.get("test-ext").await;
|
||||
assert!(entry.is_some());
|
||||
// Should still be the builtin, not the override
|
||||
assert_eq!(entry.unwrap().display_name, "Slack MCP");
|
||||
// Should be the first entry, not the duplicate
|
||||
assert_eq!(entry.unwrap().display_name, "Test First");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+85
-30
@@ -192,6 +192,12 @@ impl RegistryCatalog {
|
||||
Self::load_manifests_from_dir(&channels_dir, "channels", &mut manifests)?;
|
||||
}
|
||||
|
||||
// Load MCP servers
|
||||
let mcp_servers_dir = registry_dir.join("mcp-servers");
|
||||
if mcp_servers_dir.is_dir() {
|
||||
Self::load_manifests_from_dir(&mcp_servers_dir, "mcp-servers", &mut manifests)?;
|
||||
}
|
||||
|
||||
// Load bundles
|
||||
let bundles_path = registry_dir.join("_bundles.json");
|
||||
let bundles = if bundles_path.is_file() {
|
||||
@@ -280,8 +286,9 @@ impl RegistryCatalog {
|
||||
/// Get a manifest by name. Tries exact key match first ("tools/github"),
|
||||
/// then searches by bare name ("github").
|
||||
///
|
||||
/// If a bare name matches both a tool and a channel, returns `None`.
|
||||
/// Use a qualified key ("tools/github" or "channels/telegram") to disambiguate.
|
||||
/// If a bare name matches more than one prefix, returns `None`.
|
||||
/// Use a qualified key ("tools/github", "channels/telegram", or
|
||||
/// "mcp-servers/notion") to disambiguate.
|
||||
pub fn get(&self, name: &str) -> Option<&ExtensionManifest> {
|
||||
// Try exact key first
|
||||
if let Some(m) = self.manifests.get(name) {
|
||||
@@ -289,14 +296,15 @@ impl RegistryCatalog {
|
||||
}
|
||||
|
||||
// Try with kind prefix, detecting collisions
|
||||
let tool = self.manifests.get(&format!("tools/{}", name));
|
||||
let channel = self.manifests.get(&format!("channels/{}", name));
|
||||
let candidates: Vec<_> = ["tools", "channels", "mcp-servers"]
|
||||
.iter()
|
||||
.filter_map(|prefix| self.manifests.get(&format!("{}/{}", prefix, name)))
|
||||
.collect();
|
||||
|
||||
match (tool, channel) {
|
||||
(Some(_), Some(_)) => None, // ambiguous
|
||||
(Some(m), None) => Some(m),
|
||||
(None, Some(m)) => Some(m),
|
||||
(None, None) => None,
|
||||
if candidates.len() == 1 {
|
||||
Some(candidates[0])
|
||||
} else {
|
||||
None // ambiguous or not found
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,37 +316,63 @@ impl RegistryCatalog {
|
||||
return Ok(m);
|
||||
}
|
||||
|
||||
let has_tool = self.manifests.contains_key(&format!("tools/{}", name));
|
||||
let has_channel = self.manifests.contains_key(&format!("channels/{}", name));
|
||||
let prefixes: &[(&str, &str)] = &[
|
||||
("tools", "tool"),
|
||||
("channels", "channel"),
|
||||
("mcp-servers", "mcp_server"),
|
||||
];
|
||||
|
||||
match (has_tool, has_channel) {
|
||||
(true, true) => Err(RegistryError::AmbiguousName {
|
||||
let matches: Vec<_> = prefixes
|
||||
.iter()
|
||||
.filter(|(prefix, _)| self.manifests.contains_key(&format!("{}/{}", prefix, name)))
|
||||
.collect();
|
||||
|
||||
match matches.len() {
|
||||
0 => Err(RegistryError::ExtensionNotFound(name.to_string())),
|
||||
1 => {
|
||||
let (prefix, _) = matches[0];
|
||||
let key = format!("{}/{}", prefix, name);
|
||||
self.manifests
|
||||
.get(&key)
|
||||
.ok_or_else(|| RegistryError::ExtensionNotFound(name.to_string()))
|
||||
}
|
||||
_ => {
|
||||
let (prefix_a, kind_a) = matches[0];
|
||||
let (prefix_b, kind_b) = matches[1];
|
||||
Err(RegistryError::AmbiguousName {
|
||||
name: name.to_string(),
|
||||
kind_a: "tool",
|
||||
prefix_a: "tools",
|
||||
kind_b: "channel",
|
||||
prefix_b: "channels",
|
||||
}),
|
||||
(true, false) => Ok(self.manifests.get(&format!("tools/{}", name)).unwrap()),
|
||||
(false, true) => Ok(self.manifests.get(&format!("channels/{}", name)).unwrap()),
|
||||
(false, false) => Err(RegistryError::ExtensionNotFound(name.to_string())),
|
||||
kind_a,
|
||||
prefix_a,
|
||||
kind_b,
|
||||
prefix_b,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the full key ("tools/github" or "channels/telegram") for a manifest.
|
||||
/// Get the full key ("tools/github", "channels/telegram", or
|
||||
/// "mcp-servers/notion") for a manifest.
|
||||
pub fn key_for(&self, name: &str) -> Option<String> {
|
||||
if self.manifests.contains_key(name) {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
|
||||
let has_tool = self.manifests.contains_key(&format!("tools/{}", name));
|
||||
let has_channel = self.manifests.contains_key(&format!("channels/{}", name));
|
||||
let matches: Vec<String> = ["tools", "channels", "mcp-servers"]
|
||||
.iter()
|
||||
.filter_map(|prefix| {
|
||||
let key = format!("{}/{}", prefix, name);
|
||||
if self.manifests.contains_key(&key) {
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
match (has_tool, has_channel) {
|
||||
(true, true) => None, // ambiguous
|
||||
(true, false) => Some(format!("tools/{}", name)),
|
||||
(false, true) => Some(format!("channels/{}", name)),
|
||||
(false, false) => None,
|
||||
if matches.len() == 1 {
|
||||
matches.into_iter().next()
|
||||
} else {
|
||||
None // ambiguous or not found
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,8 +510,10 @@ mod tests {
|
||||
fn create_test_registry(dir: &Path) {
|
||||
let tools_dir = dir.join("tools");
|
||||
let channels_dir = dir.join("channels");
|
||||
let mcp_dir = dir.join("mcp-servers");
|
||||
fs::create_dir_all(&tools_dir).unwrap();
|
||||
fs::create_dir_all(&channels_dir).unwrap();
|
||||
fs::create_dir_all(&mcp_dir).unwrap();
|
||||
|
||||
fs::write(
|
||||
tools_dir.join("slack.json"),
|
||||
@@ -540,6 +576,20 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
mcp_dir.join("notion.json"),
|
||||
r#"{
|
||||
"name": "notion",
|
||||
"display_name": "Notion",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Notion for pages and databases",
|
||||
"keywords": ["notes", "wiki"],
|
||||
"url": "https://mcp.notion.com/mcp",
|
||||
"auth": "dcr"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("_bundles.json"),
|
||||
r#"{
|
||||
@@ -565,7 +615,7 @@ mod tests {
|
||||
create_test_registry(tmp.path());
|
||||
|
||||
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
|
||||
assert_eq!(catalog.all().len(), 3);
|
||||
assert_eq!(catalog.all().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -579,6 +629,9 @@ mod tests {
|
||||
|
||||
let channels = catalog.list(Some(ManifestKind::Channel), None);
|
||||
assert_eq!(channels.len(), 1);
|
||||
|
||||
let mcp_servers = catalog.list(Some(ManifestKind::McpServer), None);
|
||||
assert_eq!(mcp_servers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -603,10 +656,12 @@ mod tests {
|
||||
|
||||
// Full key
|
||||
assert!(catalog.get("tools/slack").is_some());
|
||||
assert!(catalog.get("mcp-servers/notion").is_some());
|
||||
|
||||
// Bare name
|
||||
assert!(catalog.get("slack").is_some());
|
||||
assert!(catalog.get("telegram").is_some());
|
||||
assert!(catalog.get("notion").is_some());
|
||||
|
||||
// Missing
|
||||
assert!(catalog.get("nonexistent").is_none());
|
||||
|
||||
@@ -20,6 +20,8 @@ struct EmbeddedCatalogRaw {
|
||||
#[serde(default)]
|
||||
channels: Vec<ExtensionManifest>,
|
||||
#[serde(default)]
|
||||
mcp_servers: Vec<ExtensionManifest>,
|
||||
#[serde(default)]
|
||||
bundles: BundlesFile,
|
||||
}
|
||||
|
||||
@@ -52,6 +54,10 @@ fn parsed_catalog() -> &'static ParsedCatalog {
|
||||
let key = format!("channels/{}", m.name);
|
||||
manifests.insert(key, m);
|
||||
}
|
||||
for m in raw.mcp_servers {
|
||||
let key = format!("mcp-servers/{}", m.name);
|
||||
manifests.insert(key, m);
|
||||
}
|
||||
|
||||
ParsedCatalog {
|
||||
manifests,
|
||||
|
||||
+76
-18
@@ -7,7 +7,7 @@ use tokio::fs;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::registry::catalog::RegistryError;
|
||||
use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind};
|
||||
use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind, SourceSpec};
|
||||
|
||||
// GitHub-only by design. New trusted hosts (e.g. a NEAR AI CDN) must be
|
||||
// explicitly added here; unknown hosts fall back to source build with a
|
||||
@@ -98,12 +98,29 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
||||
});
|
||||
}
|
||||
|
||||
// MCP servers are not installed via this path
|
||||
if manifest.kind == ManifestKind::McpServer {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let source = match &manifest.source {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "source",
|
||||
reason: "WASM extensions must have a source spec".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let expected_prefix = match manifest.kind {
|
||||
ManifestKind::Tool => "tools-src/",
|
||||
ManifestKind::Channel => "channels-src/",
|
||||
ManifestKind::McpServer => unreachable!(),
|
||||
};
|
||||
|
||||
if !manifest.source.dir.starts_with(expected_prefix) {
|
||||
if !source.dir.starts_with(expected_prefix) {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "source.dir",
|
||||
@@ -111,7 +128,7 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
||||
});
|
||||
}
|
||||
|
||||
let source_path = Path::new(&manifest.source.dir);
|
||||
let source_path = Path::new(&source.dir);
|
||||
let has_unsafe_component = source_path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
@@ -127,9 +144,9 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
||||
});
|
||||
}
|
||||
|
||||
let has_path_separator = manifest.source.capabilities.contains('/')
|
||||
|| manifest.source.capabilities.contains('\\')
|
||||
|| manifest.source.capabilities.contains("..");
|
||||
let has_path_separator = source.capabilities.contains('/')
|
||||
|| source.capabilities.contains('\\')
|
||||
|| source.capabilities.contains("..");
|
||||
|
||||
if has_path_separator {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
@@ -142,6 +159,18 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the source spec from a manifest, returning an error if absent.
|
||||
fn require_source(manifest: &ExtensionManifest) -> Result<&SourceSpec, RegistryError> {
|
||||
manifest
|
||||
.source
|
||||
.as_ref()
|
||||
.ok_or_else(|| RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "source",
|
||||
reason: "WASM extensions must have a source spec".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn download_failure_reason(error: &reqwest::Error) -> String {
|
||||
if error.is_timeout() {
|
||||
"request timed out".to_string()
|
||||
@@ -206,7 +235,17 @@ impl RegistryInstaller {
|
||||
) -> Result<InstallOutcome, RegistryError> {
|
||||
validate_manifest_install_inputs(manifest)?;
|
||||
|
||||
let source_dir = self.repo_root.join(&manifest.source.dir);
|
||||
if manifest.kind == ManifestKind::McpServer {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "kind",
|
||||
reason: "MCP servers cannot be installed from source".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let source = require_source(manifest)?;
|
||||
|
||||
let source_dir = self.repo_root.join(&source.dir);
|
||||
if !source_dir.exists() {
|
||||
return Err(RegistryError::ManifestRead {
|
||||
path: source_dir.clone(),
|
||||
@@ -217,6 +256,7 @@ impl RegistryInstaller {
|
||||
let target_dir = match manifest.kind {
|
||||
ManifestKind::Tool => &self.tools_dir,
|
||||
ManifestKind::Channel => &self.channels_dir,
|
||||
ManifestKind::McpServer => unreachable!(),
|
||||
};
|
||||
|
||||
fs::create_dir_all(target_dir)
|
||||
@@ -242,7 +282,7 @@ impl RegistryInstaller {
|
||||
manifest.display_name,
|
||||
source_dir.display()
|
||||
);
|
||||
let crate_name = &manifest.source.crate_name;
|
||||
let crate_name = &source.crate_name;
|
||||
let wasm_path =
|
||||
crate::registry::artifacts::build_wasm_component(&source_dir, crate_name, true)
|
||||
.await
|
||||
@@ -258,7 +298,7 @@ impl RegistryInstaller {
|
||||
.map_err(RegistryError::Io)?;
|
||||
|
||||
// Copy capabilities file
|
||||
let caps_source = source_dir.join(&manifest.source.capabilities);
|
||||
let caps_source = source_dir.join(&source.capabilities);
|
||||
let target_caps = target_dir.join(format!("{}.capabilities.json", manifest.name));
|
||||
let has_capabilities = if caps_source.exists() {
|
||||
fs::copy(&caps_source, &target_caps)
|
||||
@@ -296,6 +336,16 @@ impl RegistryInstaller {
|
||||
// catch it first.
|
||||
validate_manifest_install_inputs(manifest)?;
|
||||
|
||||
if manifest.kind == ManifestKind::McpServer {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "kind",
|
||||
reason: "MCP servers cannot be installed via the WASM installer".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let source = require_source(manifest)?;
|
||||
|
||||
let has_artifact = manifest
|
||||
.artifacts
|
||||
.get("wasm32-wasip2")
|
||||
@@ -306,7 +356,7 @@ impl RegistryInstaller {
|
||||
return self.install_from_source(manifest, force).await;
|
||||
}
|
||||
|
||||
let source_dir = self.repo_root.join(&manifest.source.dir);
|
||||
let source_dir = self.repo_root.join(&source.dir);
|
||||
|
||||
match self.install_from_artifact(manifest, force).await {
|
||||
Ok(outcome) => Ok(outcome),
|
||||
@@ -391,6 +441,13 @@ impl RegistryInstaller {
|
||||
let target_dir = match manifest.kind {
|
||||
ManifestKind::Tool => &self.tools_dir,
|
||||
ManifestKind::Channel => &self.channels_dir,
|
||||
ManifestKind::McpServer => {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "kind",
|
||||
reason: "MCP servers cannot be installed as artifacts".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fs::create_dir_all(target_dir)
|
||||
@@ -458,12 +515,9 @@ impl RegistryInstaller {
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if let Some(ref source) = manifest.source {
|
||||
// Legacy fallback: try source tree
|
||||
let caps_source = self
|
||||
.repo_root
|
||||
.join(&manifest.source.dir)
|
||||
.join(&manifest.source.capabilities);
|
||||
let caps_source = self.repo_root.join(&source.dir).join(&source.capabilities);
|
||||
if caps_source.exists() {
|
||||
fs::copy(&caps_source, &target_caps)
|
||||
.await
|
||||
@@ -472,6 +526,8 @@ impl RegistryInstaller {
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -775,17 +831,19 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
display_name: name.to_string(),
|
||||
kind,
|
||||
version: "0.1.0".to_string(),
|
||||
version: Some("0.1.0".to_string()),
|
||||
description: "test manifest".to_string(),
|
||||
keywords: Vec::new(),
|
||||
source: SourceSpec {
|
||||
source: Some(SourceSpec {
|
||||
dir: source_dir.to_string(),
|
||||
capabilities: format!("{}.capabilities.json", name),
|
||||
crate_name: name.to_string(),
|
||||
},
|
||||
}),
|
||||
artifacts,
|
||||
auth_summary: None,
|
||||
tags: Vec::new(),
|
||||
url: None,
|
||||
auth: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+192
-21
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::extensions::{AuthHint, ExtensionKind, ExtensionSource, RegistryEntry};
|
||||
|
||||
/// A single extension manifest loaded from `registry/{tools,channels}/<name>.json`.
|
||||
/// A single extension manifest loaded from `registry/{tools,channels,mcp-servers}/<name>.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExtensionManifest {
|
||||
/// Unique identifier (matches crate name stem, e.g. "slack").
|
||||
@@ -16,11 +16,12 @@ pub struct ExtensionManifest {
|
||||
/// Human-readable name (e.g. "Slack").
|
||||
pub display_name: String,
|
||||
|
||||
/// Whether this is a tool or channel.
|
||||
/// Whether this is a tool, channel, or MCP server.
|
||||
pub kind: ManifestKind,
|
||||
|
||||
/// Semver version from Cargo.toml.
|
||||
pub version: String,
|
||||
/// Semver version from Cargo.toml. Optional for MCP server manifests.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
|
||||
/// One-line description.
|
||||
pub description: String,
|
||||
@@ -29,8 +30,9 @@ pub struct ExtensionManifest {
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
|
||||
/// Source code location and build info.
|
||||
pub source: SourceSpec,
|
||||
/// Source code location and build info. Absent for MCP server manifests.
|
||||
#[serde(default)]
|
||||
pub source: Option<SourceSpec>,
|
||||
|
||||
/// Pre-built binary artifacts keyed by target triple.
|
||||
#[serde(default)]
|
||||
@@ -43,6 +45,15 @@ pub struct ExtensionManifest {
|
||||
/// Tags for filtering (e.g. "default", "messaging", "google").
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
|
||||
/// MCP server URL. Only present for `McpServer` manifests.
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
|
||||
/// MCP auth method: "dcr", "oauth_pre_configured:<setup_url>", or "none".
|
||||
/// Only present for `McpServer` manifests.
|
||||
#[serde(default)]
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
/// Extension kind as declared in manifests.
|
||||
@@ -51,6 +62,7 @@ pub struct ExtensionManifest {
|
||||
pub enum ManifestKind {
|
||||
Tool,
|
||||
Channel,
|
||||
McpServer,
|
||||
}
|
||||
|
||||
impl From<ManifestKind> for ExtensionKind {
|
||||
@@ -58,6 +70,7 @@ impl From<ManifestKind> for ExtensionKind {
|
||||
match kind {
|
||||
ManifestKind::Tool => ExtensionKind::WasmTool,
|
||||
ManifestKind::Channel => ExtensionKind::WasmChannel,
|
||||
ManifestKind::McpServer => ExtensionKind::McpServer,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +80,7 @@ impl std::fmt::Display for ManifestKind {
|
||||
match self {
|
||||
ManifestKind::Tool => write!(f, "tool"),
|
||||
ManifestKind::Channel => write!(f, "channel"),
|
||||
ManifestKind::McpServer => write!(f, "mcp_server"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,12 +167,64 @@ pub struct BundlesFile {
|
||||
impl ExtensionManifest {
|
||||
/// Convert this manifest into a [`RegistryEntry`] for use with the in-chat
|
||||
/// extension discovery system.
|
||||
pub fn to_registry_entry(&self) -> RegistryEntry {
|
||||
let buildable = ExtensionSource::WasmBuildable {
|
||||
source_dir: self.source.dir.clone(),
|
||||
build_dir: Some(self.source.dir.clone()),
|
||||
crate_name: Some(self.source.crate_name.clone()),
|
||||
///
|
||||
/// Returns `None` for MCP server manifests missing a `url` field.
|
||||
pub fn to_registry_entry(&self) -> Option<RegistryEntry> {
|
||||
if self.kind == ManifestKind::McpServer {
|
||||
return self.to_mcp_registry_entry();
|
||||
}
|
||||
|
||||
Some(self.to_wasm_registry_entry())
|
||||
}
|
||||
|
||||
/// Build a [`RegistryEntry`] for an MCP server manifest.
|
||||
fn to_mcp_registry_entry(&self) -> Option<RegistryEntry> {
|
||||
let url = match &self.url {
|
||||
Some(u) => u.clone(),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"MCP server manifest '{}' is missing 'url' field, skipping",
|
||||
self.name
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let auth_hint = match self.auth.as_deref() {
|
||||
Some("dcr") | None => AuthHint::Dcr,
|
||||
Some("none") => AuthHint::None,
|
||||
Some(other) if other.starts_with("oauth_pre_configured:") => {
|
||||
AuthHint::OAuthPreConfigured {
|
||||
setup_url: other
|
||||
.strip_prefix("oauth_pre_configured:")
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
_ => AuthHint::Dcr,
|
||||
};
|
||||
|
||||
Some(RegistryEntry {
|
||||
name: self.name.clone(),
|
||||
display_name: self.display_name.clone(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: self.description.clone(),
|
||||
keywords: self.keywords.clone(),
|
||||
source: ExtensionSource::McpUrl { url },
|
||||
fallback_source: None,
|
||||
auth_hint,
|
||||
version: self.version.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a [`RegistryEntry`] for a WASM tool or channel manifest.
|
||||
fn to_wasm_registry_entry(&self) -> RegistryEntry {
|
||||
let source_spec = self.source.as_ref();
|
||||
|
||||
let buildable = source_spec.map(|s| ExtensionSource::WasmBuildable {
|
||||
source_dir: s.dir.clone(),
|
||||
build_dir: Some(s.dir.clone()),
|
||||
crate_name: Some(s.crate_name.clone()),
|
||||
});
|
||||
|
||||
// Prefer pre-built artifact download when a URL is available,
|
||||
// with build-from-source as fallback in case the download fails (e.g., 404).
|
||||
@@ -170,13 +236,32 @@ impl ExtensionManifest {
|
||||
wasm_url: url.clone(),
|
||||
capabilities_url: artifact.capabilities_url.clone(),
|
||||
},
|
||||
Some(Box::new(buildable)),
|
||||
buildable.map(Box::new),
|
||||
)
|
||||
} else if let Some(b) = buildable {
|
||||
(b, None)
|
||||
} else {
|
||||
(buildable, None)
|
||||
// No source spec and no download URL — use a placeholder
|
||||
(
|
||||
ExtensionSource::WasmBuildable {
|
||||
source_dir: String::new(),
|
||||
build_dir: None,
|
||||
crate_name: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
} else if let Some(b) = buildable {
|
||||
(b, None)
|
||||
} else {
|
||||
(buildable, None)
|
||||
(
|
||||
ExtensionSource::WasmBuildable {
|
||||
source_dir: String::new(),
|
||||
build_dir: None,
|
||||
crate_name: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
let auth_hint = match self.auth_summary.as_ref().and_then(|a| a.method.as_deref()) {
|
||||
@@ -195,7 +280,7 @@ impl ExtensionManifest {
|
||||
source,
|
||||
fallback_source,
|
||||
auth_hint,
|
||||
version: Some(self.version.clone()),
|
||||
version: self.version.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,10 +319,10 @@ mod tests {
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
assert_eq!(manifest.name, "slack");
|
||||
assert_eq!(manifest.kind, ManifestKind::Tool);
|
||||
assert_eq!(manifest.version, "0.1.0");
|
||||
assert_eq!(manifest.version.as_deref(), Some("0.1.0"));
|
||||
assert!(manifest.tags.contains(&"default".to_string()));
|
||||
|
||||
let entry = manifest.to_registry_entry();
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
assert_eq!(entry.kind, ExtensionKind::WasmTool);
|
||||
}
|
||||
|
||||
@@ -262,7 +347,7 @@ mod tests {
|
||||
assert!(manifest.auth_summary.is_none());
|
||||
assert!(manifest.artifacts.is_empty());
|
||||
|
||||
let entry = manifest.to_registry_entry();
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
assert_eq!(entry.kind, ExtensionKind::WasmChannel);
|
||||
}
|
||||
|
||||
@@ -296,6 +381,7 @@ mod tests {
|
||||
fn test_manifest_kind_display() {
|
||||
assert_eq!(ManifestKind::Tool.to_string(), "tool");
|
||||
assert_eq!(ManifestKind::Channel.to_string(), "channel");
|
||||
assert_eq!(ManifestKind::McpServer.to_string(), "mcp_server");
|
||||
}
|
||||
|
||||
/// When a manifest has a download URL in artifacts, to_registry_entry()
|
||||
@@ -324,7 +410,7 @@ mod tests {
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
let entry = manifest.to_registry_entry();
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
|
||||
// Primary source should be WasmDownload
|
||||
assert!(
|
||||
@@ -374,7 +460,7 @@ mod tests {
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
let entry = manifest.to_registry_entry();
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(&entry.source, ExtensionSource::WasmBuildable { .. }),
|
||||
@@ -405,7 +491,7 @@ mod tests {
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
let entry = manifest.to_registry_entry();
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(&entry.source, ExtensionSource::WasmBuildable { .. }),
|
||||
@@ -416,4 +502,89 @@ mod tests {
|
||||
"Should have no fallback when already using WasmBuildable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_manifest() {
|
||||
let json = r#"{
|
||||
"name": "notion",
|
||||
"display_name": "Notion",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Notion for reading and writing pages, databases, and comments",
|
||||
"keywords": ["notes", "wiki", "docs", "pages", "database"],
|
||||
"url": "https://mcp.notion.com/mcp",
|
||||
"auth": "dcr"
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
assert_eq!(manifest.name, "notion");
|
||||
assert_eq!(manifest.kind, ManifestKind::McpServer);
|
||||
assert!(manifest.version.is_none());
|
||||
assert!(manifest.source.is_none());
|
||||
assert_eq!(manifest.url.as_deref(), Some("https://mcp.notion.com/mcp"));
|
||||
assert_eq!(manifest.auth.as_deref(), Some("dcr"));
|
||||
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
assert_eq!(entry.kind, ExtensionKind::McpServer);
|
||||
assert!(
|
||||
matches!(&entry.source, ExtensionSource::McpUrl { url } if url == "https://mcp.notion.com/mcp")
|
||||
);
|
||||
assert!(matches!(&entry.auth_hint, AuthHint::Dcr));
|
||||
assert!(entry.fallback_source.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_server_oauth_pre_configured() {
|
||||
let json = r#"{
|
||||
"name": "custom-mcp",
|
||||
"display_name": "Custom MCP",
|
||||
"kind": "mcp_server",
|
||||
"description": "Custom MCP server",
|
||||
"keywords": [],
|
||||
"url": "https://mcp.example.com",
|
||||
"auth": "oauth_pre_configured:https://example.com/setup"
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
&entry.auth_hint,
|
||||
AuthHint::OAuthPreConfigured { setup_url } if setup_url == "https://example.com/setup"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_server_auth_none() {
|
||||
let json = r#"{
|
||||
"name": "local-mcp",
|
||||
"display_name": "Local MCP",
|
||||
"kind": "mcp_server",
|
||||
"description": "Local MCP server",
|
||||
"keywords": [],
|
||||
"url": "http://localhost:8080/mcp",
|
||||
"auth": "none"
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
|
||||
assert!(matches!(&entry.auth_hint, AuthHint::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_server_missing_url_returns_none() {
|
||||
let json = r#"{
|
||||
"name": "broken-mcp",
|
||||
"display_name": "Broken MCP",
|
||||
"kind": "mcp_server",
|
||||
"description": "MCP server with no URL",
|
||||
"keywords": []
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
assert!(
|
||||
manifest.to_registry_entry().is_none(),
|
||||
"MCP manifest without url should return None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user