Files
galaxy/app/src/ai/mcp/file_based_manager.rs
T

569 lines
22 KiB
Rust

use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use galaxy_core::features::FeatureFlag;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use itertools::Itertools as _;
use repo_metadata::repositories::DetectedRepositories;
use uuid::Uuid;
use super::{FileMCPWatcher, FileMCPWatcherEvent, MCPProvider};
use crate::ai::mcp::templatable_installation::TemplatableMCPServerInstallation;
use crate::ai::mcp::ParsedTemplatableMCPServerResult;
use crate::settings::ai::AISettings;
use crate::settings::AISettingsChangedEvent;
use crate::warp_managed_paths_watcher::warp_managed_mcp_config_path;
/// Singleton model to manage file-based MCP servers.
#[derive(Default)]
pub struct FileBasedMCPManager {
/// File-based MCP server installations detected from config files.
/// Keyed by a consistent hash of the server's name, JSON template, and variable values.
file_based_servers: HashMap<u64, TemplatableMCPServerInstallation>,
/// Reverse mapping: logical root path → provider → set of server hashes.
file_based_servers_by_root: HashMap<PathBuf, HashMap<MCPProvider, HashSet<u64>>>,
/// UUIDs that were actually auto-start requested while parsing each `(root, provider)`.
/// They are temporarily stored here and removed to emit FileBasedMCPManagerEvent::CloudEnvMcpScanComplete
pending_scan_auto_started_servers_by_root:
HashMap<PathBuf, HashMap<MCPProvider, HashSet<Uuid>>>,
}
impl FileBasedMCPManager {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
if FeatureFlag::FileBasedMcp.is_enabled() {
ctx.subscribe_to_model(&FileMCPWatcher::handle(ctx), |me, _, event, ctx| {
me.handle_watcher_event(event, ctx);
});
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, ctx| {
if matches!(event, AISettingsChangedEvent::FileBasedMcpEnabled { .. }) {
me.handle_file_based_mcp_enabled_change(ctx);
}
});
}
Self {
file_based_servers: Default::default(),
file_based_servers_by_root: Default::default(),
pending_scan_auto_started_servers_by_root: Default::default(),
}
}
/// Handle an event from [`FileMCPWatcher`].
fn handle_watcher_event(&mut self, event: &FileMCPWatcherEvent, ctx: &mut ModelContext<Self>) {
match event {
FileMCPWatcherEvent::ConfigParsed {
root_path,
provider,
servers,
} => {
self.apply_parsed_servers(root_path.clone(), *provider, servers.clone(), ctx);
}
FileMCPWatcherEvent::ConfigRemoved {
root_path,
provider,
} => {
self.remove_servers_for_root_provider(root_path, *provider, ctx);
}
FileMCPWatcherEvent::CloudEnvMcpScanComplete { repo_path } => {
self.handle_cloud_environment_scan_complete(repo_path, ctx);
}
}
}
/// Get file-based MCP servers in scope for the given current working directory.
pub fn get_servers_for_working_directory(
&self,
cwd: &Path,
app: &AppContext,
) -> Vec<&TemplatableMCPServerInstallation> {
let repo_root = DetectedRepositories::as_ref(app)
.get_root_for_path(&LocalOrRemotePath::Local(cwd.to_path_buf()))
.and_then(|r| PathBuf::try_from(r).ok());
let candidate_roots = [dirs::home_dir(), repo_root];
let mut servers = Vec::new();
for root in candidate_roots.into_iter().flatten() {
// Get user and project-scoped MCP servers from all providers for the given cwd.
if let Some(provider_map) = self.file_based_servers_by_root.get(&root) {
for hash_set in provider_map.values() {
servers.extend(
hash_set
.iter()
.filter_map(|h| self.file_based_servers.get(h)),
);
}
}
}
servers
}
/// Removes all tracked servers for the given `(root_path, provider)` pair,
/// then removes any that are no longer referenced elsewhere.
fn remove_servers_for_root_provider(
&mut self,
root_path: &PathBuf,
provider: MCPProvider,
ctx: &mut ModelContext<Self>,
) {
let hashes = self
.file_based_servers_by_root
.get_mut(root_path)
.and_then(|m| m.remove(&provider));
if let Some(hashes) = hashes {
self.remove_if_orphaned(hashes, ctx);
}
}
/// Removes servers if they are no longer referenced by any (root_path, provider) pair.
/// Orphaned servers are removed from `file_based_servers` and the templatable manager is
/// notified to despawn them and purge their credentials.
fn remove_if_orphaned(
&mut self,
hashes: impl IntoIterator<Item = u64>,
ctx: &mut ModelContext<Self>,
) {
let referenced_hashes: HashSet<u64> = self
.file_based_servers_by_root
.values()
.flat_map(|provider_map| provider_map.values())
.flat_map(|hash_set| hash_set.iter().copied())
.collect();
let removed_servers: Vec<_> = hashes
.into_iter()
.filter(|hash| !referenced_hashes.contains(hash))
.filter_map(|hash| self.file_based_servers.remove(&hash))
.collect();
// Notify the templatable manager to remove orphaned servers and purge their credentials.
if !removed_servers.is_empty() {
let removed_uuids = removed_servers
.iter()
.map(|server| server.uuid())
.collect_vec();
ctx.emit(FileBasedMCPManagerEvent::DespawnServers {
installation_uuids: removed_uuids,
});
let removed_hashes = removed_servers
.iter()
.filter_map(|server| server.hash())
.collect_vec();
ctx.emit(FileBasedMCPManagerEvent::PurgeCredentials {
installation_hashes: removed_hashes,
});
}
}
/// Applies a parsed list of MCP servers
/// spawning new servers and removing servers that are no longer present.
fn apply_parsed_servers(
&mut self,
root_path: PathBuf,
provider: MCPProvider,
parsed_servers: Vec<ParsedTemplatableMCPServerResult>,
ctx: &mut ModelContext<Self>,
) {
let previous_scanned_servers: HashSet<u64> = self
.file_based_servers_by_root
.get(&root_path)
.and_then(|m| m.get(&provider))
.cloned()
.unwrap_or_default();
let mut servers_to_spawn = Vec::new();
let mut scanned_servers = HashSet::new();
for server in parsed_servers {
let Some(installation) = server.templatable_mcp_server_installation else {
continue;
};
let Some(hash) = installation.hash() else {
continue;
};
// TODO(APP-3429): Deduplicate file-based servers across provider directories.
if let Entry::Vacant(e) = self.file_based_servers.entry(hash) {
// Detected a server that hasn't previously been spawned.
// Initialize metadata and mark it for spawning.
e.insert(installation.clone());
servers_to_spawn.push(installation);
}
// In all cases, add a reference to the server in the (root_path, provider) entry.
self.file_based_servers_by_root
.entry(root_path.clone())
.or_default()
.entry(provider)
.or_default()
.insert(hash);
scanned_servers.insert(hash);
}
let auto_started_uuids = self.maybe_autostart_file_based_servers(servers_to_spawn, ctx);
self.pending_scan_auto_started_servers_by_root
.entry(root_path.clone())
.or_default()
.insert(provider, auto_started_uuids.into_iter().collect());
// Determine which servers have been removed.
let servers_to_remove = previous_scanned_servers
.difference(&scanned_servers)
.copied()
.collect_vec();
// Remove any servers that are no longer present in the config file.
if let Some(provider_map) = self.file_based_servers_by_root.get_mut(&root_path) {
if let Some(hash_set) = provider_map.get_mut(&provider) {
for hash in &servers_to_remove {
hash_set.remove(hash);
}
}
// If the set of servers for the provider is empty, remove the provider from the map.
if provider_map.get(&provider).is_some_and(|s| s.is_empty()) {
provider_map.remove(&provider);
}
}
// If the set of servers for the root path is empty, remove the root path from the map.
if self
.file_based_servers_by_root
.get(&root_path)
.is_some_and(|m| m.is_empty())
{
self.file_based_servers_by_root.remove(&root_path);
}
// If orphaned servers are found, remove them and purge their credentials.
self.remove_if_orphaned(servers_to_remove, ctx);
}
/// Returns `true` if the server identified by `hash` is referenced from any global
/// config location.
///
/// "Global" means the installation was detected outside of a user repository:
/// - For `MCPProvider::Warp`: the logical root for `~/.warp*/.mcp.json`.
/// - For any other provider: the user's home directory (e.g. `~/.claude.json`).
///
/// Project-scoped installations (those detected inside a repo) are not considered
/// global, even if they also happen to be referenced from a global location (in which
/// case this returns `true` due to the global reference).
fn is_global_server(&self, hash: u64) -> bool {
let home_dir = dirs::home_dir();
self.file_based_servers_by_root
.iter()
.any(|(root_path, provider_map)| {
provider_map.iter().any(|(provider, hashes)| {
if !hashes.contains(&hash) {
return false;
}
match provider {
MCPProvider::Warp => Self::is_global_warp_root(root_path),
MCPProvider::Claude | MCPProvider::Codex | MCPProvider::Agents => {
home_dir.as_ref().is_some_and(|home| root_path == home)
}
}
})
})
}
/// Returns `true` if the server identified by `hash` is referenced from the global
/// Warp config (`~/.warp/.mcp.json`). Global Warp servers always auto-spawn.
fn is_global_warp_server(&self, hash: u64) -> bool {
self.file_based_servers_by_root
.iter()
.any(|(root_path, provider_map)| {
Self::is_global_warp_root(root_path)
&& provider_map
.get(&MCPProvider::Warp)
.is_some_and(|hashes| hashes.contains(&hash))
})
}
fn is_global_warp_root(root_path: &Path) -> bool {
warp_managed_mcp_config_path().is_some_and(|path| root_path == path.root_path.as_path())
}
fn auto_start_decision(&self, hash: u64, file_based_mcp_enabled: bool) -> AutoStartDecision {
let server_type = if self.is_global_warp_server(hash) {
FileBasedMCPServerType::GlobalWarp
} else if self.is_global_server(hash) {
FileBasedMCPServerType::GlobalThirdParty
} else {
FileBasedMCPServerType::ProjectScoped
};
let should_autostart = match server_type {
FileBasedMCPServerType::GlobalWarp => true,
FileBasedMCPServerType::GlobalThirdParty => file_based_mcp_enabled,
FileBasedMCPServerType::ProjectScoped => false,
};
AutoStartDecision {
should_autostart,
server_type,
}
}
/// Returns the UUIDs of servers that were actually auto-started.
fn maybe_autostart_file_based_servers(
&mut self,
servers_to_consider: Vec<TemplatableMCPServerInstallation>,
ctx: &mut ModelContext<Self>,
) -> Vec<Uuid> {
if servers_to_consider.is_empty() {
return Vec::new();
}
let mcp_enabled = AISettings::as_ref(ctx).is_file_based_mcp_enabled(ctx);
// Partition servers into three buckets based on scope:
// - Global Warp: always auto-spawn.
// - Global non-Warp: auto-spawn iff the toggle is on.
// - Project-scoped (any provider): never auto-spawn; require explicit opt-in
// via the "Detected from {provider}" section of the MCP settings.
let mut to_spawn = Vec::new();
let mut auto_started_uuids = Vec::new();
for installation in servers_to_consider {
let Some(hash) = installation.hash() else {
continue;
};
let installation_uuid = installation.uuid();
let server_name = installation.templatable_mcp_server().name.clone();
let AutoStartDecision {
should_autostart, ..
} = self.auto_start_decision(hash, mcp_enabled);
if should_autostart {
log::info!(
"Auto-spawning file-based MCP server '{server_name}' ({installation_uuid})"
);
auto_started_uuids.push(installation_uuid);
to_spawn.push(installation);
}
}
if !to_spawn.is_empty() {
ctx.emit(FileBasedMCPManagerEvent::SpawnServers {
installations: to_spawn,
});
}
auto_started_uuids
}
fn handle_cloud_environment_scan_complete(
&mut self,
repo_path: &PathBuf,
ctx: &mut ModelContext<Self>,
) {
let mcp_enabled = AISettings::as_ref(ctx).is_file_based_mcp_enabled(ctx);
// FileMCPWatcher emits CloudEnvMcpScanComplete only after emitting ConfigParsed
// for every provider config in this repo scan. Each ConfigParsed call records
// the UUIDs actually emitted through SpawnServers in
// pending_scan_auto_started_servers_by_root, so this remove() returns the wait set
// for this completed scan.
let wait_server_uuids: Vec<Uuid> = self
.pending_scan_auto_started_servers_by_root
.remove(repo_path)
.into_iter()
.flat_map(|provider_map| provider_map.into_values())
.flatten()
.sorted_by_key(|uuid| uuid.to_string())
.collect();
let mut detected_servers: Vec<CloudEnvMcpScanServer> = Vec::new();
if let Some(provider_map) = self.file_based_servers_by_root.get(repo_path) {
for (provider, hash_set) in provider_map {
for hash in hash_set {
let Some(installation) = self.file_based_servers.get(hash) else {
continue;
};
let uuid = installation.uuid();
let auto_start_eligible = self
.auto_start_decision(*hash, mcp_enabled)
.should_autostart;
detected_servers.push(CloudEnvMcpScanServer {
uuid,
name: installation.templatable_mcp_server().name.clone(),
provider: *provider,
hash: *hash,
auto_start_eligible,
});
}
}
}
log::info!(
"Cloud environment file-based MCP scan complete for {}: {} detected server(s), {} auto-started server(s)",
repo_path.display(),
detected_servers.len(),
wait_server_uuids.len()
);
// Pass the UUIDs of auto-start-requested file-based MCP servers to the AgentDriver.
ctx.emit(FileBasedMCPManagerEvent::CloudEnvMcpScanComplete {
repo_path: repo_path.clone(),
detected_servers,
wait_server_uuids,
});
}
fn handle_file_based_mcp_enabled_change(&mut self, ctx: &mut ModelContext<Self>) {
// Only global third-party servers are affected by the toggle:
// - Global Warp servers always spawn regardless of the toggle.
// - Project-scoped servers (any provider) are never auto-spawned and their
// running state is managed per-card via the MCP settings UI; toggling the
// setting must not spawn or despawn them.
let global_third_party_servers: Vec<_> = self
.file_based_servers
.iter()
.filter(|(hash, _)| {
self.auto_start_decision(**hash, true).server_type
== FileBasedMCPServerType::GlobalThirdParty
})
.map(|(_, server)| server.clone())
.collect();
if !AISettings::as_ref(ctx).is_file_based_mcp_enabled(ctx) {
// Toggle off: despawn global third-party servers only.
ctx.emit(FileBasedMCPManagerEvent::DespawnServers {
installation_uuids: global_third_party_servers
.iter()
.map(|s| s.uuid())
.collect_vec(),
});
} else {
// Toggle on: spawn global third-party servers (global Warp servers are
// already running; project-scoped servers are unaffected).
ctx.emit(FileBasedMCPManagerEvent::SpawnServers {
installations: global_third_party_servers,
});
}
}
pub fn get_hash_by_uuid(&self, installation_uuid: Uuid) -> Option<u64> {
self.file_based_servers
.iter()
.find(|(_, server)| server.uuid() == installation_uuid)
.map(|(hash, _)| *hash)
}
/// Returns all detected file-based MCP server installations.
pub fn file_based_servers(&self) -> Vec<&TemplatableMCPServerInstallation> {
self.file_based_servers.values().collect()
}
/// Returns the installation with the given UUID, if any.
pub fn get_installation_by_uuid(
&self,
uuid: Uuid,
) -> Option<&TemplatableMCPServerInstallation> {
self.file_based_servers
.values()
.find(|server| server.uuid() == uuid)
}
/// Returns all root paths for the given installation scoped to a specific provider.
pub fn directory_paths_for_installation_and_provider(
&self,
uuid: Uuid,
provider: MCPProvider,
) -> Vec<PathBuf> {
let Some(hash) = self.get_hash_by_uuid(uuid) else {
return vec![];
};
self.file_based_servers_by_root
.iter()
.filter(|(_, provider_map)| {
provider_map
.get(&provider)
.is_some_and(|hashes| hashes.contains(&hash))
})
.map(|(root, _)| root.clone())
.sorted()
.collect()
}
/// Returns the directory a file-based MCP installation should be spawned from
/// when its config does not specify `working_directory`.
///
/// The spawn root is the directory the config was discovered in, with one
/// exception: global Warp installs are discovered in `~/.warp*/`, which
/// isn't a useful cwd for spawned processes, so they are remapped to the
/// home directory instead.
/// - Project-scoped installations: the repo root.
/// - Global installations (`~/.warp-core/.mcp.json`, `~/.claude.json`, etc.): the
/// home directory.
///
/// If the installation is referenced from multiple roots, the lexicographically
/// smallest is returned for determinism. Returns `None` for installations that
/// are not tracked by `FileBasedMCPManager` (e.g. cloud-templated installs).
pub fn spawn_root_for_installation(&self, uuid: Uuid) -> Option<PathBuf> {
let hash = self.get_hash_by_uuid(uuid)?;
let discovery_root = self
.file_based_servers_by_root
.iter()
.filter(|(_, provider_map)| provider_map.values().any(|hashes| hashes.contains(&hash)))
.map(|(root, _)| root.clone())
.sorted()
.next()?;
// Global Warp installs live under `~/.warp*/`, which is internal Warp
// state rather than a meaningful working directory. Map them to the
// home dir so all global installs (Warp and third-party) share a
// consistent cwd.
if self.is_global_warp_server(hash) {
return dirs::home_dir().or(Some(discovery_root));
}
Some(discovery_root)
}
}
struct AutoStartDecision {
should_autostart: bool,
server_type: FileBasedMCPServerType,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FileBasedMCPServerType {
/// A file-based MCP server detected from Warp's global managed config.
GlobalWarp,
/// A file-based MCP server detected from a non-Warp provider's global user config.
GlobalThirdParty,
/// A file-based MCP server detected from a project/repository-scoped config.
ProjectScoped,
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub struct CloudEnvMcpScanServer {
pub uuid: Uuid,
pub name: String,
pub provider: MCPProvider,
pub hash: u64,
pub auto_start_eligible: bool,
}
pub enum FileBasedMCPManagerEvent {
SpawnServers {
installations: Vec<TemplatableMCPServerInstallation>,
},
DespawnServers {
installation_uuids: Vec<Uuid>,
},
PurgeCredentials {
installation_hashes: Vec<u64>,
},
CloudEnvMcpScanComplete {
repo_path: PathBuf,
#[allow(dead_code)]
detected_servers: Vec<CloudEnvMcpScanServer>,
wait_server_uuids: Vec<Uuid>,
},
}
impl Entity for FileBasedMCPManager {
type Event = FileBasedMCPManagerEvent;
}
impl SingletonEntity for FileBasedMCPManager {}
#[cfg(test)]
#[path = "file_based_manager_tests.rs"]
mod tests;