From c916069dd236832c38a2a032ea8c3e578fe5585e Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sat, 14 Mar 2026 18:59:55 +0000 Subject: [PATCH] refactor(registry): move MCP servers from code to JSON manifests (#1144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * ci: re-trigger CI with correct base branch (staging) Co-Authored-By: Claude Opus 4.6 (1M context) * 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) * 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) * fix(registry): address re-review — skip invalid MCP entries, fix install order - to_registry_entry() now returns Option; 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/code_style.yml | 27 ++- build.rs | 12 +- registry/mcp-servers/asana.json | 9 + registry/mcp-servers/cloudflare.json | 9 + registry/mcp-servers/intercom.json | 9 + registry/mcp-servers/linear.json | 9 + registry/mcp-servers/notion.json | 9 + registry/mcp-servers/sentry.json | 9 + registry/mcp-servers/stripe.json | 9 + src/app.rs | 2 +- src/cli/registry.rs | 24 ++- src/extensions/registry.rs | 305 +++++++-------------------- src/registry/catalog.rs | 117 +++++++--- src/registry/embedded.rs | 6 + src/registry/installer.rs | 94 +++++++-- src/registry/manifest.rs | 213 +++++++++++++++++-- 16 files changed, 552 insertions(+), 311 deletions(-) create mode 100644 registry/mcp-servers/asana.json create mode 100644 registry/mcp-servers/cloudflare.json create mode 100644 registry/mcp-servers/intercom.json create mode 100644 registry/mcp-servers/linear.json create mode 100644 registry/mcp-servers/notion.json create mode 100644 registry/mcp-servers/sentry.json create mode 100644 registry/mcp-servers/stripe.json diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index b5055717..705f261b 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -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 diff --git a/build.rs b/build.rs index 1f644aaf..c2b93923 100644 --- a/build.rs +++ b/build.rs @@ -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, ); diff --git a/registry/mcp-servers/asana.json b/registry/mcp-servers/asana.json new file mode 100644 index 00000000..8a4f69b3 --- /dev/null +++ b/registry/mcp-servers/asana.json @@ -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" +} diff --git a/registry/mcp-servers/cloudflare.json b/registry/mcp-servers/cloudflare.json new file mode 100644 index 00000000..85f6045a --- /dev/null +++ b/registry/mcp-servers/cloudflare.json @@ -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" +} diff --git a/registry/mcp-servers/intercom.json b/registry/mcp-servers/intercom.json new file mode 100644 index 00000000..b5cc214f --- /dev/null +++ b/registry/mcp-servers/intercom.json @@ -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" +} diff --git a/registry/mcp-servers/linear.json b/registry/mcp-servers/linear.json new file mode 100644 index 00000000..c88a5d6b --- /dev/null +++ b/registry/mcp-servers/linear.json @@ -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" +} diff --git a/registry/mcp-servers/notion.json b/registry/mcp-servers/notion.json new file mode 100644 index 00000000..7e7c3ae7 --- /dev/null +++ b/registry/mcp-servers/notion.json @@ -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" +} diff --git a/registry/mcp-servers/sentry.json b/registry/mcp-servers/sentry.json new file mode 100644 index 00000000..3dee5f55 --- /dev/null +++ b/registry/mcp-servers/sentry.json @@ -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" +} diff --git a/registry/mcp-servers/stripe.json b/registry/mcp-servers/stripe.json new file mode 100644 index 00000000..557907a5 --- /dev/null +++ b/registry/mcp-servers/stripe.json @@ -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" +} diff --git a/src/app.rs b/src/app.rs index da77d3f3..00804de1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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(), diff --git a/src/cli/registry.rs b/src/cli/registry.rs index 0126db6f..a2fa8b02 100644 --- a/src/cli/registry.rs +++ b/src/cli/registry.rs @@ -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(", ")); } - println!("\nSource:"); - println!(" Directory: {}", manifest.source.dir); - println!(" Crate: {}", manifest.source.crate_name); - println!(" Capabilities: {}", manifest.source.capabilities); + if let Some(ref source) = manifest.source { + println!("\nSource:"); + 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):"); diff --git a/src/extensions/registry.rs b/src/extensions/registry.rs index 35a45862..ec471834 100644 --- a/src/extensions/registry.rs +++ b/src/extensions/registry.rs @@ -232,198 +232,11 @@ pub fn builtin_entries() -> Vec { } /// 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) -> Vec { - 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 = 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(), - keywords: vec![], - source: ExtensionSource::McpUrl { - url: "https://other.slack.com".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://first.example.com".to_string(), + }, + fallback_source: None, + auth_hint: AuthHint::Dcr, + version: None, }, - 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] diff --git a/src/registry/catalog.rs b/src/registry/catalog.rs index 8cf99aaa..175a6b51 100644 --- a/src/registry/catalog.rs +++ b/src/registry/catalog.rs @@ -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 { - 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())), + 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, + 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 { 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 = ["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()); diff --git a/src/registry/embedded.rs b/src/registry/embedded.rs index 4c61ada7..379e06e5 100644 --- a/src/registry/embedded.rs +++ b/src/registry/embedded.rs @@ -20,6 +20,8 @@ struct EmbeddedCatalogRaw { #[serde(default)] channels: Vec, #[serde(default)] + mcp_servers: Vec, + #[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, diff --git a/src/registry/installer.rs b/src/registry/installer.rs index 91f536f4..8d070eea 100644 --- a/src/registry/installer.rs +++ b/src/registry/installer.rs @@ -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 { 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, } } diff --git a/src/registry/manifest.rs b/src/registry/manifest.rs index a000442a..e70f1f31 100644 --- a/src/registry/manifest.rs +++ b/src/registry/manifest.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::extensions::{AuthHint, ExtensionKind, ExtensionSource, RegistryEntry}; -/// A single extension manifest loaded from `registry/{tools,channels}/.json`. +/// A single extension manifest loaded from `registry/{tools,channels,mcp-servers}/.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, /// One-line description. pub description: String, @@ -29,8 +30,9 @@ pub struct ExtensionManifest { #[serde(default)] pub keywords: Vec, - /// 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, /// 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, + + /// MCP server URL. Only present for `McpServer` manifests. + #[serde(default)] + pub url: Option, + + /// MCP auth method: "dcr", "oauth_pre_configured:", or "none". + /// Only present for `McpServer` manifests. + #[serde(default)] + pub auth: Option, } /// Extension kind as declared in manifests. @@ -51,6 +62,7 @@ pub struct ExtensionManifest { pub enum ManifestKind { Tool, Channel, + McpServer, } impl From for ExtensionKind { @@ -58,6 +70,7 @@ impl From 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 { + 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 { + 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" + ); + } }