748 lines
28 KiB
Rust
748 lines
28 KiB
Rust
use async_channel::Sender;
|
|
use futures::Future;
|
|
use regex::Regex;
|
|
use repo_metadata::{
|
|
repositories::{DetectedRepositories, DetectedRepositoriesEvent, RepoDetectionSource},
|
|
repository::{Repository, RepositorySubscriber, SubscriberId},
|
|
watcher::{DirectoryWatcher, RepositoryUpdate},
|
|
};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::io::ErrorKind;
|
|
use std::path::{Path, PathBuf};
|
|
use std::pin::Pin;
|
|
use std::sync::LazyLock;
|
|
use galaxy_core::safe_warn;
|
|
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
|
use watcher::HomeDirectoryWatcherEvent;
|
|
|
|
use crate::ai::mcp::{
|
|
home_config_file_path, parsing::normalize_codex_toml_to_json, MCPProvider,
|
|
ParsedTemplatableMCPServerResult,
|
|
};
|
|
use crate::warp_managed_paths_watcher::{
|
|
warp_managed_mcp_config_path, WarpManagedPathsWatcher, WarpManagedPathsWatcherEvent,
|
|
};
|
|
use crate::HomeDirectoryWatcher;
|
|
use strum::IntoEnumIterator;
|
|
|
|
static ENV_VAR_REGEX: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"\$\{([^}]+)\}").expect("Regex is valid"));
|
|
|
|
/// Matches home config paths that are exactly one directory deep (e.g. `.codex/config.toml`,
|
|
/// `.warp-core/.mcp.json`), capturing the parent directory component.
|
|
static HOME_SUBDIR_REGEX: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"^([^/]+)/[^/]+$").expect("Regex is valid"));
|
|
|
|
/// Returns the subdirectory under the home directory that needs its own [`DirectoryWatcher`],
|
|
/// inferred from the provider's home config path. Matches paths that are exactly one directory
|
|
/// deep (e.g. `.codex/config.toml` → `.codex`, `.warp-core/.mcp.json` → `.warp-core`). Returns `None`
|
|
/// when the config file lives directly in the home dir (e.g. `.claude.json`) and is already
|
|
/// covered by `HomeDirectoryWatcher`.
|
|
fn home_subdir_to_watch(provider: MCPProvider) -> Option<PathBuf> {
|
|
let path_str = provider.home_config_path().to_str()?;
|
|
HOME_SUBDIR_REGEX
|
|
.captures(path_str)
|
|
.and_then(|caps| caps.get(1))
|
|
.map(|m| PathBuf::from(m.as_str()))
|
|
}
|
|
|
|
/// Messages sent from `RepositorySubscriber`s to detect file-based MCPs.
|
|
enum FileMCPDetectionMessage {
|
|
/// Initial scan of a watched directory.
|
|
InitialScan {
|
|
/// The directory the watcher is registered on.
|
|
/// Can be different from the directory that detected servers are stored in, i.e. for home subdir watchers.
|
|
watched_dir: PathBuf,
|
|
/// The directory that detected servers are stored in.
|
|
/// Either the home directory for home watchers, or the repository root for project watchers.
|
|
stored_dir: PathBuf,
|
|
},
|
|
/// Incremental file system updates from a watched directory.
|
|
Update {
|
|
watched_dir: PathBuf,
|
|
stored_dir: PathBuf,
|
|
update: RepositoryUpdate,
|
|
},
|
|
}
|
|
|
|
/// Single repository subscriber type used for all watched directories (project repos and home
|
|
/// provider subdirs). Carries the logical `stored_dir` key captured at registration time.
|
|
struct FileMCPSubscriber {
|
|
// Maps to the key in `file_based_servers_by_root` that contains servers detected by this subscriber.
|
|
// For home provider subdirs, this is the home directory.
|
|
// For project repos, this is the repository root.
|
|
stored_dir: PathBuf,
|
|
message_tx: Sender<FileMCPDetectionMessage>,
|
|
}
|
|
|
|
impl RepositorySubscriber for FileMCPSubscriber {
|
|
fn on_scan(
|
|
&mut self,
|
|
repository: &Repository,
|
|
_ctx: &mut ModelContext<Repository>,
|
|
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
|
let watched_dir = repository.root_dir().to_local_path_lossy();
|
|
let stored_dir = self.stored_dir.clone();
|
|
let tx = self.message_tx.clone();
|
|
|
|
Box::pin(async move {
|
|
let _ = tx
|
|
.send(FileMCPDetectionMessage::InitialScan {
|
|
watched_dir,
|
|
stored_dir,
|
|
})
|
|
.await;
|
|
})
|
|
}
|
|
|
|
fn on_files_updated(
|
|
&mut self,
|
|
repository: &Repository,
|
|
update: &RepositoryUpdate,
|
|
_ctx: &mut ModelContext<Repository>,
|
|
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
|
let watched_dir = repository.root_dir().to_local_path_lossy();
|
|
let stored_dir = self.stored_dir.clone();
|
|
let tx = self.message_tx.clone();
|
|
let update = update.clone();
|
|
|
|
Box::pin(async move {
|
|
let _ = tx
|
|
.send(FileMCPDetectionMessage::Update {
|
|
watched_dir,
|
|
stored_dir,
|
|
update,
|
|
})
|
|
.await;
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Model that watches the filesystem for file-based MCP config changes and emits
|
|
/// [`FileMCPWatcherEvent`]s.
|
|
pub struct FileMCPWatcher {
|
|
file_mcp_tx: Sender<FileMCPDetectionMessage>,
|
|
/// Watcher handles for home provider subdirectories (e.g. `~/.codex`), keyed by subdir path.
|
|
/// Used to cleanup watchers when the subdir is deleted at runtime.
|
|
home_provider_watchers: HashMap<PathBuf, (ModelHandle<Repository>, SubscriberId)>,
|
|
/// Set of project repository root paths we are already watching for file-based MCP configs.
|
|
/// Used purely for deduplication — we never tear down project watchers during the session.
|
|
project_repo_watchers: HashSet<PathBuf>,
|
|
/// Tracks how many provider config files remain to be parsed for each cloud environment repo.
|
|
/// When the count reaches zero, a `CloudEnvironmentScanComplete` event is emitted.
|
|
cloud_env_pending: HashMap<PathBuf, usize>,
|
|
}
|
|
|
|
impl FileMCPWatcher {
|
|
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
|
let (file_mcp_tx, file_mcp_rx) = async_channel::unbounded::<FileMCPDetectionMessage>();
|
|
|
|
ctx.spawn_stream_local(
|
|
file_mcp_rx,
|
|
|me, message, ctx| {
|
|
me.handle_file_mcp_detection_message(message, ctx);
|
|
},
|
|
|_, _| {},
|
|
);
|
|
|
|
// Subscribe to changes to detected repositories.
|
|
ctx.subscribe_to_model(&DetectedRepositories::handle(ctx), {
|
|
let file_mcp_tx = file_mcp_tx.clone();
|
|
move |me, event, ctx| {
|
|
let DetectedRepositoriesEvent::DetectedGitRepo { repository, source } = event;
|
|
// Register MCP servers for repos the user actively navigated to, and for
|
|
// repos cloned during cloud agent environment preparation.
|
|
if matches!(
|
|
source,
|
|
RepoDetectionSource::TerminalNavigation
|
|
| RepoDetectionSource::CloudEnvironmentPrep
|
|
) {
|
|
let repo_path = repository.as_ref(ctx).root_dir().to_local_path_lossy();
|
|
if matches!(source, RepoDetectionSource::CloudEnvironmentPrep) {
|
|
// Track how many MCP config files remain to be parsed for the cloud environment repo.
|
|
let count =
|
|
providers_in_scope(repo_path.clone(), repo_path.clone()).count();
|
|
me.cloud_env_pending.insert(repo_path.clone(), count);
|
|
}
|
|
me.register_repo_for_file_mcp_watching(repo_path, ctx, file_mcp_tx.clone());
|
|
}
|
|
}
|
|
});
|
|
|
|
// Subscribe to changes to top-level files in the home directory.
|
|
ctx.subscribe_to_model(&HomeDirectoryWatcher::handle(ctx), |me, event, ctx| {
|
|
me.handle_home_directory_watcher_event(event, ctx);
|
|
});
|
|
ctx.subscribe_to_model(&WarpManagedPathsWatcher::handle(ctx), |me, event, ctx| {
|
|
me.handle_warp_managed_paths_event(event, ctx);
|
|
});
|
|
|
|
let mut home_provider_watchers = HashMap::new();
|
|
if let Some(mcp_config_path) = warp_managed_mcp_config_path() {
|
|
Self::spawn_config_parse(
|
|
mcp_config_path.config_path,
|
|
mcp_config_path.root_path,
|
|
MCPProvider::Warp,
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
if let Some(home_dir) = dirs::home_dir() {
|
|
for provider in MCPProvider::iter() {
|
|
if provider == MCPProvider::Warp {
|
|
continue;
|
|
}
|
|
match home_subdir_to_watch(provider) {
|
|
None => {
|
|
// Initial scan of config files for providers whose config lives directly in
|
|
// home (i.e. ~/.claude.json). HomeDirectoryWatcher handles incremental updates.
|
|
let Some(config_path) = home_config_file_path(provider) else {
|
|
continue;
|
|
};
|
|
Self::spawn_config_parse(config_path, home_dir.clone(), provider, ctx);
|
|
}
|
|
Some(subdir) => {
|
|
// For providers whose home config lives in a subdir (e.g. ~/.codex for Codex)
|
|
// start watching the subdir for file-based MCP servers, if it exists.
|
|
let subdir_path = home_dir.join(&subdir);
|
|
// Note: this will fail if the subdir doesn't exist yet.
|
|
// We register upon creation of the subdir via HomeDirectoryWatcher.
|
|
Self::watch_home_provider_dir(
|
|
&subdir_path,
|
|
home_dir.clone(),
|
|
file_mcp_tx.clone(),
|
|
&mut home_provider_watchers,
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Self {
|
|
file_mcp_tx,
|
|
home_provider_watchers,
|
|
project_repo_watchers: HashSet::new(),
|
|
cloud_env_pending: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Register a project repo for file-based MCP watching via DirectoryWatcher.
|
|
fn register_repo_for_file_mcp_watching(
|
|
&mut self,
|
|
repo_path: PathBuf,
|
|
ctx: &mut ModelContext<Self>,
|
|
file_mcp_tx: Sender<FileMCPDetectionMessage>,
|
|
) {
|
|
if self.project_repo_watchers.contains(&repo_path) {
|
|
return;
|
|
}
|
|
|
|
let Some(repo_handle) =
|
|
DetectedRepositories::as_ref(ctx).get_watched_repo_for_path(&repo_path, ctx)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let start = repo_handle.update(ctx, |repo, ctx| {
|
|
repo.start_watching(
|
|
Box::new(FileMCPSubscriber {
|
|
stored_dir: repo_path.clone(),
|
|
message_tx: file_mcp_tx,
|
|
}),
|
|
ctx,
|
|
)
|
|
});
|
|
let subscriber_id = start.subscriber_id;
|
|
// Store optimistically; removed in the error callback below if registration fails.
|
|
self.project_repo_watchers.insert(repo_path.clone());
|
|
|
|
ctx.spawn(start.registration_future, move |me, res, ctx| {
|
|
if let Err(err) = res {
|
|
log::warn!(
|
|
"Failed to start watching {repo_path} for file-based MCP servers: {err}",
|
|
repo_path = repo_path.display(),
|
|
);
|
|
me.project_repo_watchers.remove(&repo_path);
|
|
repo_handle.update(ctx, |repo, ctx| {
|
|
repo.stop_watching(subscriber_id, ctx);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Register a home provider subdir (e.g. `~/.codex`) for watching via `DirectoryWatcher`,
|
|
/// storing the handle in `home_provider_watchers` for later cleanup.
|
|
fn watch_home_provider_dir(
|
|
subdir_path: &Path,
|
|
home_dir: PathBuf,
|
|
file_mcp_tx: Sender<FileMCPDetectionMessage>,
|
|
home_provider_watchers: &mut HashMap<PathBuf, (ModelHandle<Repository>, SubscriberId)>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
// If the subdir is already being watched, return early.
|
|
if home_provider_watchers.contains_key(subdir_path) {
|
|
return;
|
|
}
|
|
|
|
let Ok(std_path) =
|
|
galaxy_util::standardized_path::StandardizedPath::from_local_canonicalized(subdir_path)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let repo_handle = match DirectoryWatcher::handle(ctx)
|
|
.update(ctx, |watcher, ctx| watcher.add_directory(std_path, ctx))
|
|
{
|
|
Ok(handle) => handle,
|
|
Err(err) => {
|
|
log::warn!(
|
|
"Failed to register {} for file-based MCP watching: {err}",
|
|
subdir_path.display(),
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let subscriber = Box::new(FileMCPSubscriber {
|
|
stored_dir: home_dir,
|
|
message_tx: file_mcp_tx,
|
|
});
|
|
let start = repo_handle.update(ctx, |repo, ctx| repo.start_watching(subscriber, ctx));
|
|
let subscriber_id = start.subscriber_id;
|
|
// Store optimistically; removed in the error callback below if registration fails.
|
|
home_provider_watchers.insert(
|
|
subdir_path.to_path_buf(),
|
|
(repo_handle.clone(), subscriber_id),
|
|
);
|
|
|
|
let subdir_path_owned = subdir_path.to_path_buf();
|
|
ctx.spawn(start.registration_future, move |me, res, ctx| {
|
|
if let Err(err) = res {
|
|
log::warn!(
|
|
"Failed to start watching {} for file-based MCP servers: {err}",
|
|
subdir_path_owned.display(),
|
|
);
|
|
me.home_provider_watchers.remove(&subdir_path_owned);
|
|
repo_handle.update(ctx, |repo, ctx| {
|
|
repo.stop_watching(subscriber_id, ctx);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Handle incoming home directory watcher events.
|
|
///
|
|
/// For providers whose config sits directly in home (no subdir), handles add/delete of
|
|
/// the config file itself. For providers with a home subdir, handles creation and deletion
|
|
/// of that subdir, registering or cleaning up a `DirectoryWatcher` accordingly.
|
|
fn handle_home_directory_watcher_event(
|
|
&mut self,
|
|
event: &HomeDirectoryWatcherEvent,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let HomeDirectoryWatcherEvent::HomeFilesChanged(fs_event) = event;
|
|
let Some(home_dir) = dirs::home_dir() else {
|
|
return;
|
|
};
|
|
|
|
for provider in MCPProvider::iter() {
|
|
if provider == MCPProvider::Warp {
|
|
continue;
|
|
}
|
|
match home_subdir_to_watch(provider) {
|
|
None => {
|
|
// Config lives directly in home (e.g. ~/.claude.json).
|
|
// HomeDirectoryWatcher watches home non-recursively, so we handle
|
|
// add/delete/move of the config file here.
|
|
let Some(config_path) = home_config_file_path(provider) else {
|
|
continue;
|
|
};
|
|
|
|
let was_deleted = fs_event.deleted.contains(&config_path)
|
|
|| fs_event.moved.values().any(|v| v == &config_path);
|
|
if was_deleted {
|
|
ctx.emit(FileMCPWatcherEvent::ConfigRemoved {
|
|
root_path: home_dir.clone(),
|
|
provider,
|
|
});
|
|
}
|
|
|
|
let was_added = fs_event.added_or_updated_iter().any(|p| p == &config_path)
|
|
|| fs_event.moved.contains_key(&config_path);
|
|
if was_added {
|
|
self.update_servers_from_config_file(
|
|
&config_path,
|
|
home_dir.clone(),
|
|
provider,
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
Some(subdir) => {
|
|
// Config lives in a home subdir (e.g. ~/.codex/config.toml).
|
|
// HomeDirectoryWatcher detects creation/deletion of the subdir itself;
|
|
// file changes within it are handled by the registered DirectoryWatcher.
|
|
let subdir_path = home_dir.join(&subdir);
|
|
|
|
let subdir_added = fs_event.added.contains(&subdir_path)
|
|
|| fs_event.moved.contains_key(&subdir_path);
|
|
if subdir_added {
|
|
// If the subdir (i.e. ~/.codex) is created, start watching it for file-based MCP servers.
|
|
Self::watch_home_provider_dir(
|
|
&subdir_path,
|
|
home_dir.clone(),
|
|
self.file_mcp_tx.clone(),
|
|
&mut self.home_provider_watchers,
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
let subdir_deleted = fs_event.deleted.contains(&subdir_path)
|
|
|| fs_event.moved.values().any(|v| v == &subdir_path);
|
|
if subdir_deleted {
|
|
if let Some((repo_handle, id)) =
|
|
self.home_provider_watchers.remove(&subdir_path)
|
|
{
|
|
repo_handle.update(ctx, |repo, ctx| repo.stop_watching(id, ctx));
|
|
}
|
|
ctx.emit(FileMCPWatcherEvent::ConfigRemoved {
|
|
root_path: home_dir.clone(),
|
|
provider,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_warp_managed_paths_event(
|
|
&mut self,
|
|
event: &WarpManagedPathsWatcherEvent,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let WarpManagedPathsWatcherEvent::FilesChanged(update) = event;
|
|
let Some(mcp_config_path) = warp_managed_mcp_config_path() else {
|
|
return;
|
|
};
|
|
let config_path = mcp_config_path.config_path;
|
|
let was_deleted = update
|
|
.deleted
|
|
.iter()
|
|
.any(|target| target.path == config_path)
|
|
|| update
|
|
.moved
|
|
.values()
|
|
.any(|target| target.path == config_path);
|
|
let was_added = update
|
|
.added_or_modified()
|
|
.any(|target| target.path == config_path)
|
|
|| update.moved.keys().any(|target| target.path == config_path);
|
|
self.handle_single_config_update(
|
|
mcp_config_path.root_path,
|
|
MCPProvider::Warp,
|
|
config_path,
|
|
was_deleted,
|
|
was_added,
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
/// Handle incoming file-based MCP detection messages.
|
|
fn handle_file_mcp_detection_message(
|
|
&mut self,
|
|
message: FileMCPDetectionMessage,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
match message {
|
|
FileMCPDetectionMessage::InitialScan {
|
|
watched_dir,
|
|
stored_dir: root_path,
|
|
} => {
|
|
self.handle_dir_initial_scan(watched_dir, root_path, ctx);
|
|
}
|
|
FileMCPDetectionMessage::Update {
|
|
watched_dir,
|
|
stored_dir: root_path,
|
|
update,
|
|
} => {
|
|
self.handle_dir_update(watched_dir, root_path, update, ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle an initial scan of a watched directory.
|
|
///
|
|
/// `providers_in_scope` scopes the scan to the watcher: for a project watcher
|
|
/// (`watched_dir == root_path`) both Claude and Codex configs are scanned; for a home
|
|
/// Codex watcher (`watched_dir = ~/.codex`, `root_path = ~/`) only Codex's config passes.
|
|
fn handle_dir_initial_scan(
|
|
&mut self,
|
|
watched_dir: PathBuf,
|
|
root_path: PathBuf,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
for (provider, config_path) in providers_in_scope(root_path.clone(), watched_dir.clone()) {
|
|
self.update_servers_from_config_file(&config_path, root_path.clone(), provider, ctx);
|
|
}
|
|
}
|
|
|
|
/// Handle incremental file system updates from a watched directory.
|
|
fn handle_dir_update(
|
|
&mut self,
|
|
watched_dir: PathBuf,
|
|
root_path: PathBuf,
|
|
update: RepositoryUpdate,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let mut configs_to_update = Vec::new();
|
|
|
|
for (provider, config_path) in providers_in_scope(root_path.clone(), watched_dir.clone()) {
|
|
let was_deleted = update.deleted.iter().any(|f| f.path == config_path)
|
|
|| update.moved.values().any(|f| f.path == config_path);
|
|
let was_added = update.added_or_modified().any(|f| f.path == config_path)
|
|
|| update.moved.keys().any(|f| f.path == config_path);
|
|
configs_to_update.push((provider, config_path, was_deleted, was_added));
|
|
}
|
|
|
|
for (provider, config_path, was_deleted, was_added) in configs_to_update {
|
|
self.handle_single_config_update(
|
|
root_path.clone(),
|
|
provider,
|
|
config_path,
|
|
was_deleted,
|
|
was_added,
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn handle_single_config_update(
|
|
&mut self,
|
|
root_path: PathBuf,
|
|
provider: MCPProvider,
|
|
config_path: PathBuf,
|
|
was_deleted: bool,
|
|
was_added: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
if was_deleted {
|
|
ctx.emit(FileMCPWatcherEvent::ConfigRemoved {
|
|
root_path: root_path.clone(),
|
|
provider,
|
|
});
|
|
}
|
|
if was_added {
|
|
self.update_servers_from_config_file(&config_path, root_path, provider, ctx);
|
|
}
|
|
}
|
|
|
|
fn spawn_config_parse(
|
|
config_path: PathBuf,
|
|
root_path: PathBuf,
|
|
provider: MCPProvider,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let root_path_for_callback = root_path.clone();
|
|
let _ = ctx.spawn(
|
|
async move { parse_mcp_config_file(&config_path, provider).await },
|
|
move |_me, parsed, ctx| {
|
|
ctx.emit(FileMCPWatcherEvent::ConfigParsed {
|
|
root_path: root_path_for_callback,
|
|
provider,
|
|
servers: parsed,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Asynchronously reads and parses the MCP configuration file at `config_file_path`,
|
|
/// then emits a [`FileMCPWatcherEvent::ConfigParsed`] event.
|
|
fn update_servers_from_config_file(
|
|
&mut self,
|
|
config_file_path: &Path,
|
|
root_path: PathBuf,
|
|
provider: MCPProvider,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let config_file_path = config_file_path.to_path_buf();
|
|
let _ = ctx.spawn(
|
|
async move { parse_mcp_config_file(&config_file_path, provider).await },
|
|
move |me, servers, ctx| {
|
|
let repo_path_for_countdown = root_path.clone();
|
|
ctx.emit(FileMCPWatcherEvent::ConfigParsed {
|
|
root_path,
|
|
provider,
|
|
servers,
|
|
});
|
|
if let Some(count) = me.cloud_env_pending.get_mut(&repo_path_for_countdown) {
|
|
*count = count.saturating_sub(1);
|
|
if *count == 0 {
|
|
// If we've parsed all MCP config files for the cloud environment repo, emit a `CloudEnvironmentScanComplete` event.
|
|
me.cloud_env_pending.remove(&repo_path_for_countdown);
|
|
ctx.emit(FileMCPWatcherEvent::CloudEnvMcpScanComplete {
|
|
repo_path: repo_path_for_countdown,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Returns an iterator of `(provider, config_path)` pairs for MCP providers whose configuration file
|
|
/// paths fall within the watched directory.
|
|
fn providers_in_scope(
|
|
root_path: PathBuf,
|
|
watched_dir: PathBuf,
|
|
) -> impl Iterator<Item = (MCPProvider, PathBuf)> {
|
|
MCPProvider::iter().flat_map(move |provider| {
|
|
let mut results = HashSet::new();
|
|
for path in [
|
|
root_path.join(provider.home_config_path()),
|
|
root_path.join(provider.project_config_path()),
|
|
] {
|
|
if path.starts_with(&watched_dir) {
|
|
results.insert((provider, path));
|
|
}
|
|
}
|
|
results.into_iter()
|
|
})
|
|
}
|
|
|
|
/// Substitutes environment variables in the format ${VAR_NAME} in the given JSON string.
|
|
/// Returns an error if any environment variable is not found, as the server cannot be started.
|
|
fn substitute_env_vars(json_content: &str) -> Result<String, anyhow::Error> {
|
|
let mut result = json_content.to_string();
|
|
|
|
for capture in ENV_VAR_REGEX.captures_iter(json_content) {
|
|
if let Some(var_match) = capture.get(1) {
|
|
let var_name = var_match.as_str();
|
|
match std::env::var(var_name) {
|
|
Ok(value) if !value.is_empty() => {
|
|
let placeholder = format!("${{{}}}", var_name);
|
|
result = result.replace(&placeholder, &value);
|
|
}
|
|
_ => {
|
|
return Err(anyhow::anyhow!(
|
|
"Missing or empty environment variable: {var_name}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Asynchronously reads and parses an MCP config file and returns parsed MCP servers.
|
|
/// Dispatches to the appropriate parser based on `provider` rather than inferring from path.
|
|
/// Returns an empty vec if the file doesn't exist or parsing fails.
|
|
async fn parse_mcp_config_file(
|
|
file_path: &Path,
|
|
provider: MCPProvider,
|
|
) -> Vec<ParsedTemplatableMCPServerResult> {
|
|
let file_contents = match async_fs::read_to_string(file_path).await {
|
|
Ok(contents) => contents,
|
|
Err(err) if err.kind() == ErrorKind::NotFound => return vec![],
|
|
Err(err) => {
|
|
safe_warn!(
|
|
safe: (
|
|
"Failed to read MCP config file: {}",
|
|
err
|
|
),
|
|
full: (
|
|
"Failed to read MCP config file {}: {}",
|
|
file_path.display(),
|
|
err
|
|
)
|
|
);
|
|
return vec![];
|
|
}
|
|
};
|
|
|
|
let json = match provider {
|
|
MCPProvider::Codex => match normalize_codex_toml_to_json(&file_contents) {
|
|
Ok(json) => json,
|
|
Err(err) => {
|
|
safe_warn!(
|
|
safe: (
|
|
"Failed to normalize Codex TOML: {:#}",
|
|
err
|
|
),
|
|
full: (
|
|
"Failed to normalize Codex TOML {}: {:#}",
|
|
file_path.display(),
|
|
err
|
|
)
|
|
);
|
|
return vec![];
|
|
}
|
|
},
|
|
MCPProvider::Claude | MCPProvider::Warp | MCPProvider::Agents => file_contents,
|
|
};
|
|
|
|
let resolved_contents = match substitute_env_vars(&json) {
|
|
Ok(resolved) => resolved,
|
|
Err(err) => {
|
|
safe_warn!(
|
|
safe: (
|
|
"Cannot start MCP servers - missing required environment variables: {}",
|
|
err
|
|
),
|
|
full: (
|
|
"Cannot start MCP servers from {} - missing required environment variables: {}",
|
|
file_path.display(),
|
|
err
|
|
)
|
|
);
|
|
return vec![];
|
|
}
|
|
};
|
|
|
|
match ParsedTemplatableMCPServerResult::from_config_file_json(&resolved_contents) {
|
|
Ok(parsed_servers) => parsed_servers,
|
|
Err(err) => {
|
|
safe_warn!(
|
|
safe: (
|
|
"Failed to parse MCP servers: {:#}",
|
|
err
|
|
),
|
|
full: (
|
|
"Failed to parse MCP servers from {}: {:#}",
|
|
file_path.display(),
|
|
err
|
|
)
|
|
);
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Events sent from [`FileMCPWatcher`] to [`FileBasedMCPManager`] via the watcher channel.
|
|
pub enum FileMCPWatcherEvent {
|
|
/// A config file was successfully parsed; delivers the full snapshot for `(root_path, provider)`.
|
|
ConfigParsed {
|
|
root_path: PathBuf,
|
|
provider: MCPProvider,
|
|
servers: Vec<ParsedTemplatableMCPServerResult>,
|
|
},
|
|
/// A config file was deleted; all servers for `(root_path, provider)` should be removed.
|
|
ConfigRemoved {
|
|
root_path: PathBuf,
|
|
provider: MCPProvider,
|
|
},
|
|
/// All provider config files for a cloud environment repo have been parsed.
|
|
CloudEnvMcpScanComplete { repo_path: PathBuf },
|
|
}
|
|
|
|
impl Entity for FileMCPWatcher {
|
|
type Event = FileMCPWatcherEvent;
|
|
}
|
|
|
|
impl SingletonEntity for FileMCPWatcher {}
|
|
|
|
#[cfg(test)]
|
|
#[path = "file_mcp_watcher_tests.rs"]
|
|
mod tests;
|