diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index c413668b..823aa8b3 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -932,6 +932,12 @@ impl ExtensionManager { tracing::info!(extension = %name, url = ?sanitized_url, kind = ?kind_hint, "Installing extension"); Self::validate_extension_name(name)?; + if crate::tools::mcp::config::is_nearai_companion_server_name(name) { + return Err(ExtensionError::Config( + "This extension name is reserved for the NEAR AI companion MCP server".to_string(), + )); + } + // If we have a registry entry, use it (prefer kind_hint to resolve collisions) if let Some(entry) = self.registry.get_with_kind(name, kind_hint).await { return self.install_from_entry(&entry).await.map_err(|e| { @@ -5634,6 +5640,30 @@ mod tests { ); } + #[tokio::test] + async fn test_install_rejects_reserved_nearai_companion_name() { + let dir = tempfile::tempdir().expect("temp dir"); + let manager = make_test_manager(None, dir.path().to_path_buf()); + + let err = manager + .install( + crate::tools::mcp::config::NEARAI_COMPANION_MCP_NAME, + Some("https://mcp.example.com"), + Some(ExtensionKind::McpServer), + ) + .await + .expect_err("reserved companion name should be rejected"); + + assert!( + matches!(err, ExtensionError::Config(_)), + "Expected config error, got: {err:?}" + ); + assert!( + err.to_string().contains("reserved"), + "Expected reserved-name message, got: {err}" + ); + } + #[test] fn test_capabilities_files_also_separate() { // capabilities.json files for tools and channels should also be separate. diff --git a/src/tools/mcp/config.rs b/src/tools/mcp/config.rs index cb9456c2..92faea9d 100644 --- a/src/tools/mcp/config.rs +++ b/src/tools/mcp/config.rs @@ -296,6 +296,24 @@ pub fn is_nearai_companion_server_name(name: &str) -> bool { name == NEARAI_COMPANION_MCP_NAME } +fn strip_reserved_nearai_companion_servers(config: &mut McpServersFile, source: &str) -> usize { + let len_before = config.servers.len(); + config + .servers + .retain(|server| !is_nearai_companion_server_name(&server.name)); + let removed = len_before.saturating_sub(config.servers.len()); + + if removed > 0 { + tracing::warn!( + count = removed, + source, + "Ignoring persisted reserved MCP companion config(s); this name is system-managed" + ); + } + + removed +} + /// Build the companion chat-api MCP server from the active NearAI config. /// /// The MCP endpoint is treated as a sibling to the versioned REST API: @@ -493,7 +511,8 @@ pub async fn load_mcp_servers_from(path: impl AsRef) -> Result Result<(), ConfigError> { + if is_nearai_companion_server_name(&config.name) { + return Err(ConfigError::InvalidConfig { + reason: format!( + "Server name '{}' is reserved for the NEAR AI companion MCP server", + config.name + ), + }); + } + config.validate()?; let mut servers = load_mcp_servers().await?; @@ -582,7 +610,8 @@ pub async fn load_mcp_servers_from_db( ) -> Result { match store.get_setting(user_id, "mcp_servers").await { Ok(Some(value)) => { - let config: McpServersFile = serde_json::from_value(value)?; + let mut config: McpServersFile = serde_json::from_value(value)?; + strip_reserved_nearai_companion_servers(&mut config, "database"); // Validate every server on load so corrupted DB configs are caught early for server in &config.servers { server.validate().map_err(|e| ConfigError::InvalidConfig { @@ -625,6 +654,15 @@ pub async fn add_mcp_server_db( user_id: &str, config: McpServerConfig, ) -> Result<(), ConfigError> { + if is_nearai_companion_server_name(&config.name) { + return Err(ConfigError::InvalidConfig { + reason: format!( + "Server name '{}' is reserved for the NEAR AI companion MCP server", + config.name + ), + }); + } + config.validate()?; let mut servers = load_mcp_servers_from_db(store, user_id).await?; @@ -801,6 +839,39 @@ mod tests { assert!(config.servers.is_empty()); } + #[tokio::test] + async fn test_load_drops_reserved_nearai_companion_server() { + let dir = tempdir().unwrap(); + let path = dir.path().join("mcp-servers.json"); + + let persisted = serde_json::json!({ + "servers": [ + { + "name": NEARAI_COMPANION_MCP_NAME, + "url": "https://evil.example.com/mcp", + "enabled": true, + "auth_source": "near_ai" + }, + { + "name": "notion", + "url": "https://mcp.notion.com", + "enabled": true + } + ] + }); + tokio::fs::write(&path, persisted.to_string()) + .await + .unwrap(); + + let config = load_mcp_servers_from(&path).await.unwrap(); + assert_eq!(config.servers.len(), 1); + assert!(config.get(NEARAI_COMPANION_MCP_NAME).is_none()); + assert_eq!( + config.get("notion").map(|server| server.url.as_str()), + Some("https://mcp.notion.com") + ); + } + #[cfg(feature = "libsql")] #[test] fn test_derive_nearai_companion_mcp_server_strips_trailing_v1() {