Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
use crate::ai::mcp::templatable_installation::TemplatableMCPServerInstallation;
|
||||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::MCPProvider;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
pub struct FileBasedMCPManager {}
|
||||
|
||||
impl FileBasedMCPManager {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub fn get_servers_for_working_directory(
|
||||
&self,
|
||||
_cwd: &Path,
|
||||
_app: &AppContext,
|
||||
) -> Vec<&TemplatableMCPServerInstallation> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub fn file_based_servers(&self) -> Vec<&TemplatableMCPServerInstallation> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub fn get_installation_by_uuid(
|
||||
&self,
|
||||
_uuid: Uuid,
|
||||
) -> Option<&TemplatableMCPServerInstallation> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn directory_paths_for_installation_and_provider(
|
||||
&self,
|
||||
_uuid: Uuid,
|
||||
_provider: MCPProvider,
|
||||
) -> Vec<PathBuf> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FileBasedMCPManager {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for FileBasedMCPManager {}
|
||||
@@ -0,0 +1,15 @@
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
pub struct FileMCPWatcher {}
|
||||
|
||||
impl FileMCPWatcher {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FileMCPWatcher {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for FileMCPWatcher {}
|
||||
@@ -0,0 +1,433 @@
|
||||
use super::MCPProvider;
|
||||
use super::{FileMCPWatcher, FileMCPWatcherEvent};
|
||||
use itertools::Itertools as _;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use std::collections::{hash_map::Entry, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::mcp::{
|
||||
templatable_installation::TemplatableMCPServerInstallation,
|
||||
ParsedTemplatableMCPServerResult,
|
||||
},
|
||||
settings::{ai::AISettings, AISettingsChangedEvent},
|
||||
warp_managed_paths_watcher::warp_data_dir,
|
||||
};
|
||||
|
||||
/// 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>>>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(cwd);
|
||||
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);
|
||||
}
|
||||
|
||||
// If file-based MCP is enabled, spawn any new servers.
|
||||
self.spawn_file_based_servers(servers_to_spawn, ctx);
|
||||
|
||||
// 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`: `warp_data_dir()` (i.e. `~/.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();
|
||||
let warp_root = warp_data_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 => root_path == &warp_root,
|
||||
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 {
|
||||
let warp_root = warp_data_dir();
|
||||
self.file_based_servers_by_root
|
||||
.get(&warp_root)
|
||||
.and_then(|provider_map| provider_map.get(&MCPProvider::Warp))
|
||||
.is_some_and(|hashes| hashes.contains(&hash))
|
||||
}
|
||||
|
||||
fn spawn_file_based_servers(
|
||||
&mut self,
|
||||
servers_to_spawn: Vec<TemplatableMCPServerInstallation>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if servers_to_spawn.is_empty() {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
for installation in servers_to_spawn {
|
||||
let Some(hash) = installation.hash() else {
|
||||
continue;
|
||||
};
|
||||
if self.is_global_warp_server(hash) || (self.is_global_server(hash) && mcp_enabled) {
|
||||
to_spawn.push(installation);
|
||||
}
|
||||
|
||||
// Project-scoped installations are intentionally dropped from auto-spawn.
|
||||
}
|
||||
|
||||
if !to_spawn.is_empty() {
|
||||
ctx.emit(FileBasedMCPManagerEvent::SpawnServers {
|
||||
installations: to_spawn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cloud_environment_scan_complete(
|
||||
&mut self,
|
||||
repo_path: &PathBuf,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Retrieve UUIDs of all file-based MCP servers in the repository.
|
||||
let server_uuids: Vec<Uuid> = self
|
||||
.file_based_servers_by_root
|
||||
.get(repo_path)
|
||||
.map(|provider_map| {
|
||||
provider_map
|
||||
.values()
|
||||
.flat_map(|hash_set| hash_set.iter())
|
||||
.filter_map(|hash| self.file_based_servers.get(hash))
|
||||
.map(|installation| installation.uuid())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Pass the UUIDs of all detected file-based MCP servers to the AgentDriver.
|
||||
ctx.emit(FileBasedMCPManagerEvent::CloudEnvMcpScanComplete {
|
||||
repo_path: repo_path.clone(),
|
||||
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.is_global_server(**hash) && !self.is_global_warp_server(**hash)
|
||||
})
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum FileBasedMCPManagerEvent {
|
||||
SpawnServers {
|
||||
installations: Vec<TemplatableMCPServerInstallation>,
|
||||
},
|
||||
DespawnServers {
|
||||
installation_uuids: Vec<Uuid>,
|
||||
},
|
||||
PurgeCredentials {
|
||||
installation_hashes: Vec<u64>,
|
||||
},
|
||||
CloudEnvMcpScanComplete {
|
||||
repo_path: PathBuf,
|
||||
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;
|
||||
@@ -0,0 +1,513 @@
|
||||
use super::{FileBasedMCPManager, FileBasedMCPManagerEvent, MCPProvider};
|
||||
use crate::ai::mcp::FileMCPWatcher;
|
||||
use crate::ai::mcp::ParsedTemplatableMCPServerResult;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::settings::{AISettings, FocusedTerminalInfo};
|
||||
use crate::warp_managed_paths_watcher::{warp_data_dir, WarpManagedPathsWatcher};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use repo_metadata::{
|
||||
repositories::DetectedRepositories, watcher::DirectoryWatcher, RepoMetadataModel,
|
||||
};
|
||||
use settings::Setting as _;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{App, Entity, ModelHandle, SingletonEntity as _};
|
||||
use watcher::HomeDirectoryWatcher;
|
||||
|
||||
// Helper to initialize dependencies and return FileBasedMCPManager handle
|
||||
fn setup_app(app: &mut App) -> warpui::ModelHandle<FileBasedMCPManager> {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
app.add_singleton_model(FileMCPWatcher::new);
|
||||
app.add_singleton_model(AISettings::new_with_defaults);
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
app.add_singleton_model(FocusedTerminalInfo::new);
|
||||
app.add_singleton_model(FileBasedMCPManager::new)
|
||||
}
|
||||
|
||||
/// Parses an MCP JSON string directly into server results, bypassing file I/O.
|
||||
/// Used in tests to exercise `apply_parsed_servers` without needing files on disk.
|
||||
fn parse_mcp_json(json: &str) -> Vec<ParsedTemplatableMCPServerResult> {
|
||||
ParsedTemplatableMCPServerResult::from_user_json(json).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Test-only event collector for `FileBasedMCPManagerEvent`s.
|
||||
#[derive(Default)]
|
||||
struct ManagerEvents {
|
||||
spawned_uuids: Vec<Uuid>,
|
||||
despawned_uuids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
impl Entity for ManagerEvents {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
/// Subscribe a fresh `ManagerEvents` collector to the given manager handle.
|
||||
fn subscribe_events(
|
||||
app: &mut App,
|
||||
manager: &ModelHandle<FileBasedMCPManager>,
|
||||
) -> ModelHandle<ManagerEvents> {
|
||||
let events = app.add_model(|_| ManagerEvents::default());
|
||||
events.update(app, |_, ctx| {
|
||||
ctx.subscribe_to_model(manager, |me, event, _| match event {
|
||||
FileBasedMCPManagerEvent::SpawnServers { installations } => me
|
||||
.spawned_uuids
|
||||
.extend(installations.iter().map(|i| i.uuid())),
|
||||
FileBasedMCPManagerEvent::DespawnServers { installation_uuids } => {
|
||||
me.despawned_uuids
|
||||
.extend(installation_uuids.iter().copied());
|
||||
}
|
||||
FileBasedMCPManagerEvent::PurgeCredentials { .. }
|
||||
| FileBasedMCPManagerEvent::CloudEnvMcpScanComplete { .. } => {}
|
||||
});
|
||||
});
|
||||
events
|
||||
}
|
||||
|
||||
/// Set the `file_based_mcp_enabled` toggle without going through preferences.
|
||||
fn set_file_based_mcp_enabled(app: &mut App, enabled: bool) {
|
||||
AISettings::handle(app).update(app, |settings, ctx| {
|
||||
settings
|
||||
.file_based_mcp_enabled
|
||||
.load_value(enabled, true, ctx)
|
||||
.expect("load_value should succeed in tests");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_file_based_servers_spawns_new_servers() {
|
||||
let repo_path = PathBuf::from("/tmp/test-repo");
|
||||
let parsed = parse_mcp_json(
|
||||
r#"{"test-server": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-example"]}}"#,
|
||||
);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let manager_handle = setup_app(&mut app);
|
||||
|
||||
manager_handle.update(&mut app, |manager, ctx| {
|
||||
// Initially, no servers should exist
|
||||
assert_eq!(
|
||||
manager.file_based_servers.len(),
|
||||
0,
|
||||
"Should start with no servers"
|
||||
);
|
||||
|
||||
// Apply the pre-parsed servers directly
|
||||
manager.apply_parsed_servers(repo_path.clone(), MCPProvider::Claude, parsed, ctx);
|
||||
|
||||
// Verify a new server was added to the data structures
|
||||
assert_eq!(
|
||||
manager.file_based_servers.len(),
|
||||
1,
|
||||
"Should have one server in file_based_servers"
|
||||
);
|
||||
|
||||
// Verify the repo is mapped to the server
|
||||
let server_hashes = manager
|
||||
.file_based_servers_by_root
|
||||
.get(&repo_path)
|
||||
.and_then(|m| m.get(&MCPProvider::Claude))
|
||||
.unwrap();
|
||||
assert_eq!(server_hashes.len(), 1, "Repo should reference one server");
|
||||
|
||||
// Verify the server hash is consistent
|
||||
let hash = *server_hashes.iter().next().unwrap();
|
||||
assert!(
|
||||
manager.file_based_servers.contains_key(&hash),
|
||||
"Server hash should exist in file_based_servers"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_file_based_servers_adds_reference_to_existing_server() {
|
||||
let json = r#"{"shared-server": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-example"]}}"#;
|
||||
let repo1 = PathBuf::from("/tmp/test-repo-1");
|
||||
let repo2 = PathBuf::from("/tmp/test-repo-2");
|
||||
let parsed1 = parse_mcp_json(json);
|
||||
let parsed2 = parse_mcp_json(json);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let manager_handle = setup_app(&mut app);
|
||||
|
||||
manager_handle.update(&mut app, |manager, ctx| {
|
||||
// First repo adds the server
|
||||
manager.apply_parsed_servers(repo1.clone(), MCPProvider::Claude, parsed1, ctx);
|
||||
|
||||
assert_eq!(
|
||||
manager.file_based_servers.len(),
|
||||
1,
|
||||
"Should have one server after first scan"
|
||||
);
|
||||
let first_hash = *manager.file_based_servers.keys().next().unwrap();
|
||||
let first_installation_uuid = manager
|
||||
.file_based_servers
|
||||
.get(&first_hash)
|
||||
.unwrap()
|
||||
.uuid();
|
||||
|
||||
// Second repo with same config should reuse the server
|
||||
manager.apply_parsed_servers(repo2.clone(), MCPProvider::Claude, parsed2, ctx);
|
||||
|
||||
// Should still have only one server (same hash)
|
||||
assert_eq!(
|
||||
manager.file_based_servers.len(),
|
||||
1,
|
||||
"Should still have only one server (shared)"
|
||||
);
|
||||
|
||||
// Both repos should reference the same server
|
||||
let hash1 = manager
|
||||
.file_based_servers_by_root
|
||||
.get(&repo1)
|
||||
.and_then(|m| m.get(&MCPProvider::Claude))
|
||||
.and_then(|s| s.iter().copied().next())
|
||||
.unwrap();
|
||||
|
||||
let hash2 = manager
|
||||
.file_based_servers_by_root
|
||||
.get(&repo2)
|
||||
.and_then(|m| m.get(&MCPProvider::Claude))
|
||||
.and_then(|s| s.iter().copied().next())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
hash1, hash2,
|
||||
"Both repos should reference the same server hash"
|
||||
);
|
||||
|
||||
// Verify the installation at the hash hasn't changed
|
||||
let installation_uuid_after_rescan = manager
|
||||
.file_based_servers
|
||||
.get(&hash1)
|
||||
.unwrap()
|
||||
.uuid();
|
||||
assert_eq!(
|
||||
first_installation_uuid,
|
||||
installation_uuid_after_rescan,
|
||||
"Installation UUID must remain stable when reusing a hash - OAuth flow depends on this mapping"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_file_based_servers_removes_unreferenced_servers() {
|
||||
let repo_path = PathBuf::from("/tmp/test-repo");
|
||||
let parsed1 = parse_mcp_json(r#"{"server1": {"command": "npx", "args": ["-y", "server1"]}}"#);
|
||||
let parsed2 = parse_mcp_json(r#"{"server2": {"command": "npx", "args": ["-y", "server2"]}}"#);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let manager_handle = setup_app(&mut app);
|
||||
|
||||
manager_handle.update(&mut app, |manager, ctx| {
|
||||
manager.apply_parsed_servers(repo_path.clone(), MCPProvider::Claude, parsed1, ctx);
|
||||
|
||||
assert_eq!(
|
||||
manager.file_based_servers.len(),
|
||||
1,
|
||||
"Should have one server after first scan"
|
||||
);
|
||||
let first_hash = *manager.file_based_servers.keys().next().unwrap();
|
||||
|
||||
// Second scan: different server (old one should be removed)
|
||||
manager.apply_parsed_servers(repo_path.clone(), MCPProvider::Claude, parsed2, ctx);
|
||||
|
||||
// Should still have one server, but it's a different one
|
||||
assert_eq!(
|
||||
manager.file_based_servers.len(),
|
||||
1,
|
||||
"Should still have one server after second scan"
|
||||
);
|
||||
|
||||
let second_hash = *manager.file_based_servers.keys().next().unwrap();
|
||||
assert_ne!(
|
||||
first_hash, second_hash,
|
||||
"The server hash should be different (new server)"
|
||||
);
|
||||
|
||||
// Old server should be gone
|
||||
assert!(
|
||||
!manager.file_based_servers.contains_key(&first_hash),
|
||||
"Old server should be removed"
|
||||
);
|
||||
|
||||
// Repo should only reference the new server
|
||||
let server_hashes = manager
|
||||
.file_based_servers_by_root
|
||||
.get(&repo_path)
|
||||
.and_then(|m| m.get(&MCPProvider::Claude))
|
||||
.unwrap();
|
||||
assert_eq!(server_hashes.len(), 1, "Repo should reference one server");
|
||||
assert_eq!(
|
||||
*server_hashes.iter().next().unwrap(),
|
||||
second_hash,
|
||||
"Repo should reference the new server"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A globally-scoped Warp installation always auto-spawns, regardless of the
|
||||
/// `file_based_mcp_enabled` toggle.
|
||||
#[test]
|
||||
fn test_global_warp_server_always_spawns() {
|
||||
let _flag_guard = FeatureFlag::FileBasedMcp.override_enabled(true);
|
||||
let warp_root = warp_data_dir();
|
||||
let parsed = parse_mcp_json(r#"{"global-warp": {"command": "npx", "args": ["warp"]}}"#);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let manager = setup_app(&mut app);
|
||||
let events = subscribe_events(&mut app, &manager);
|
||||
|
||||
// Toggle is off by default; global Warp server should still spawn.
|
||||
manager.update(&mut app, |m, ctx| {
|
||||
m.apply_parsed_servers(warp_root.clone(), MCPProvider::Warp, parsed, ctx);
|
||||
});
|
||||
|
||||
events.update(&mut app, |e, _| {
|
||||
assert_eq!(
|
||||
e.spawned_uuids.len(),
|
||||
1,
|
||||
"Global Warp server should auto-spawn regardless of toggle"
|
||||
);
|
||||
});
|
||||
|
||||
// Flipping the toggle must not despawn the global Warp server.
|
||||
set_file_based_mcp_enabled(&mut app, true);
|
||||
set_file_based_mcp_enabled(&mut app, false);
|
||||
|
||||
events.update(&mut app, |e, _| {
|
||||
assert!(
|
||||
e.despawned_uuids.is_empty(),
|
||||
"Global Warp server should never be despawned by toggle changes, got: {:?}",
|
||||
e.despawned_uuids
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A globally-scoped non-Warp installation only auto-spawns when the toggle is on.
|
||||
#[test]
|
||||
fn test_global_non_warp_server_respects_toggle() {
|
||||
let _flag_guard = FeatureFlag::FileBasedMcp.override_enabled(true);
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
// Skip on platforms where a home dir isn't available (shouldn't happen on
|
||||
// our supported platforms, but guard to avoid false failures).
|
||||
return;
|
||||
};
|
||||
let parsed = parse_mcp_json(r#"{"global-claude": {"command": "npx", "args": ["claude"]}}"#);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let manager = setup_app(&mut app);
|
||||
let events = subscribe_events(&mut app, &manager);
|
||||
|
||||
// Toggle is off by default: detection should NOT auto-spawn.
|
||||
manager.update(&mut app, |m, ctx| {
|
||||
m.apply_parsed_servers(home_dir.clone(), MCPProvider::Claude, parsed, ctx);
|
||||
});
|
||||
events.update(&mut app, |e, _| {
|
||||
assert!(
|
||||
e.spawned_uuids.is_empty(),
|
||||
"Global non-Warp server must not auto-spawn while toggle is off, got: {:?}",
|
||||
e.spawned_uuids
|
||||
);
|
||||
});
|
||||
|
||||
let installation_uuid = manager.update(&mut app, |m, _| {
|
||||
let servers = m.file_based_servers();
|
||||
assert_eq!(servers.len(), 1);
|
||||
servers[0].uuid()
|
||||
});
|
||||
|
||||
// Toggle on: the global non-Warp server should be spawned.
|
||||
set_file_based_mcp_enabled(&mut app, true);
|
||||
events.update(&mut app, |e, _| {
|
||||
assert_eq!(
|
||||
e.spawned_uuids,
|
||||
vec![installation_uuid],
|
||||
"Global non-Warp server should spawn when toggle flips on"
|
||||
);
|
||||
});
|
||||
|
||||
// Toggle off: the global non-Warp server should be despawned.
|
||||
set_file_based_mcp_enabled(&mut app, false);
|
||||
events.update(&mut app, |e, _| {
|
||||
assert_eq!(
|
||||
e.despawned_uuids,
|
||||
vec![installation_uuid],
|
||||
"Global non-Warp server should despawn when toggle flips off"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Project-scoped installations (both Warp and third-party) never auto-spawn on
|
||||
/// detection, and the toggle must not spawn or despawn them either.
|
||||
#[test]
|
||||
fn test_project_scoped_servers_never_auto_spawn() {
|
||||
let _flag_guard = FeatureFlag::FileBasedMcp.override_enabled(true);
|
||||
let repo_path = PathBuf::from("/tmp/warp-test-repo");
|
||||
let claude_parsed =
|
||||
parse_mcp_json(r#"{"proj-claude": {"command": "npx", "args": ["proj-claude"]}}"#);
|
||||
let warp_parsed = parse_mcp_json(r#"{"proj-warp": {"command": "npx", "args": ["proj-warp"]}}"#);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let manager = setup_app(&mut app);
|
||||
let events = subscribe_events(&mut app, &manager);
|
||||
|
||||
manager.update(&mut app, |m, ctx| {
|
||||
m.apply_parsed_servers(repo_path.clone(), MCPProvider::Claude, claude_parsed, ctx);
|
||||
m.apply_parsed_servers(repo_path.clone(), MCPProvider::Warp, warp_parsed, ctx);
|
||||
});
|
||||
|
||||
// Neither detection should emit a spawn event.
|
||||
events.update(&mut app, |e, _| {
|
||||
assert!(
|
||||
e.spawned_uuids.is_empty(),
|
||||
"Project-scoped installations must not auto-spawn, got: {:?}",
|
||||
e.spawned_uuids
|
||||
);
|
||||
});
|
||||
|
||||
let project_uuids: HashSet<Uuid> = manager.update(&mut app, |m, _| {
|
||||
m.file_based_servers().iter().map(|i| i.uuid()).collect()
|
||||
});
|
||||
assert_eq!(
|
||||
project_uuids.len(),
|
||||
2,
|
||||
"Both project-scoped installations should be tracked"
|
||||
);
|
||||
|
||||
// Flipping the toggle must not spawn or despawn project-scoped servers.
|
||||
set_file_based_mcp_enabled(&mut app, true);
|
||||
set_file_based_mcp_enabled(&mut app, false);
|
||||
|
||||
events.update(&mut app, |e, _| {
|
||||
assert!(
|
||||
e.spawned_uuids.is_empty(),
|
||||
"Toggle flip must not spawn project-scoped servers, got: {:?}",
|
||||
e.spawned_uuids
|
||||
);
|
||||
assert!(
|
||||
e.despawned_uuids.is_empty(),
|
||||
"Toggle flip must not despawn project-scoped servers, got: {:?}",
|
||||
e.despawned_uuids
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// An installation referenced from both a global location and a project location
|
||||
/// is considered global (and thus gated only by the toggle for non-Warp providers).
|
||||
#[test]
|
||||
fn test_server_referenced_from_both_global_and_project_is_global() {
|
||||
let _flag_guard = FeatureFlag::FileBasedMcp.override_enabled(true);
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
return;
|
||||
};
|
||||
let repo_path = PathBuf::from("/tmp/warp-test-repo-shared");
|
||||
let json = r#"{"shared-claude": {"command": "npx", "args": ["shared"]}}"#;
|
||||
let global_parsed = parse_mcp_json(json);
|
||||
let project_parsed = parse_mcp_json(json);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let manager = setup_app(&mut app);
|
||||
let events = subscribe_events(&mut app, &manager);
|
||||
|
||||
// Register both a global and a project reference for the same server.
|
||||
manager.update(&mut app, |m, ctx| {
|
||||
m.apply_parsed_servers(home_dir.clone(), MCPProvider::Claude, global_parsed, ctx);
|
||||
m.apply_parsed_servers(repo_path.clone(), MCPProvider::Claude, project_parsed, ctx);
|
||||
});
|
||||
|
||||
// Toggle starts off, so no auto-spawn yet.
|
||||
events.update(&mut app, |e, _| {
|
||||
assert!(
|
||||
e.spawned_uuids.is_empty(),
|
||||
"Server should not auto-spawn while toggle is off"
|
||||
);
|
||||
});
|
||||
|
||||
let installation_uuid = manager.update(&mut app, |m, _| {
|
||||
let servers = m.file_based_servers();
|
||||
assert_eq!(
|
||||
servers.len(),
|
||||
1,
|
||||
"Both roots should reference the same server"
|
||||
);
|
||||
servers[0].uuid()
|
||||
});
|
||||
|
||||
// Flipping the toggle on should spawn the server because it's also global.
|
||||
set_file_based_mcp_enabled(&mut app, true);
|
||||
events.update(&mut app, |e, _| {
|
||||
assert_eq!(
|
||||
e.spawned_uuids,
|
||||
vec![installation_uuid],
|
||||
"Global reference should make the server eligible for toggle-driven spawn"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_file_based_servers_removes_server_only_when_no_refs() {
|
||||
let json = r#"{"shared-server": {"command": "npx", "args": ["-y", "shared"]}}"#;
|
||||
let repo1 = PathBuf::from("/tmp/test-repo-1");
|
||||
let repo2 = PathBuf::from("/tmp/test-repo-2");
|
||||
let parsed1 = parse_mcp_json(json);
|
||||
let parsed2 = parse_mcp_json(json);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let manager_handle = setup_app(&mut app);
|
||||
|
||||
manager_handle.update(&mut app, |manager, ctx| {
|
||||
// Both repos add the same server
|
||||
manager.apply_parsed_servers(repo1.clone(), MCPProvider::Claude, parsed1, ctx);
|
||||
manager.apply_parsed_servers(repo2.clone(), MCPProvider::Claude, parsed2, ctx);
|
||||
|
||||
assert_eq!(
|
||||
manager.file_based_servers.len(),
|
||||
1,
|
||||
"Should have one shared server"
|
||||
);
|
||||
let server_hash = *manager.file_based_servers.keys().next().unwrap();
|
||||
|
||||
// repo1 removes the server (empty scan simulates deleted config file)
|
||||
manager.apply_parsed_servers(repo1.clone(), MCPProvider::Claude, vec![], ctx);
|
||||
|
||||
// Server should still exist because repo2 still references it
|
||||
assert_eq!(
|
||||
manager.file_based_servers.len(),
|
||||
1,
|
||||
"Server should still exist (repo2 still references it)"
|
||||
);
|
||||
assert!(
|
||||
manager.file_based_servers.contains_key(&server_hash),
|
||||
"Server should still be in the map"
|
||||
);
|
||||
|
||||
// Now repo2 removes the server too
|
||||
manager.apply_parsed_servers(repo2.clone(), MCPProvider::Claude, vec![], ctx);
|
||||
|
||||
// Now the server should be removed (no more references)
|
||||
assert_eq!(
|
||||
manager.file_based_servers.len(),
|
||||
0,
|
||||
"Server should be removed (no more references)"
|
||||
);
|
||||
assert!(
|
||||
!manager.file_based_servers.contains_key(&server_hash),
|
||||
"Server should be completely removed"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
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 warp_core::safe_warn;
|
||||
use warpui::{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/.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/.mcp.json` → `.warp`). 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) =
|
||||
warp_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;
|
||||
@@ -0,0 +1,74 @@
|
||||
use super::substitute_env_vars;
|
||||
use std::env;
|
||||
|
||||
fn cleanup_env_vars(vars: &[&str]) {
|
||||
for var in vars {
|
||||
env::remove_var(var);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substitute_env_vars_success() {
|
||||
let test_vars = ["FOO", "BAZ", "REPEATED"];
|
||||
|
||||
// Setup environment variables
|
||||
env::set_var("FOO", "bar");
|
||||
env::set_var("BAZ", "qux");
|
||||
env::set_var("REPEATED", "value");
|
||||
|
||||
// Test 1: Single variable substitution
|
||||
let input = r#"{"key": "${FOO}"}"#;
|
||||
let result = substitute_env_vars(input).expect("Single variable substitution should succeed");
|
||||
assert_eq!(
|
||||
result, r#"{"key": "bar"}"#,
|
||||
"Single variable FOO should be replaced with 'bar'"
|
||||
);
|
||||
|
||||
// Test 2: Multiple different variables
|
||||
let input = r#"{"key": "${FOO}", "other": "${BAZ}"}"#;
|
||||
let result = substitute_env_vars(input).expect("Multiple variable substitution should succeed");
|
||||
assert_eq!(
|
||||
result, r#"{"key": "bar", "other": "qux"}"#,
|
||||
"Multiple variables FOO and BAZ should be replaced"
|
||||
);
|
||||
|
||||
// Test 3: Multiple occurrences of same variable
|
||||
let input = r#"{"a": "${REPEATED}", "b": "${REPEATED}", "c": "prefix_${REPEATED}_suffix"}"#;
|
||||
let result = substitute_env_vars(input).expect("Repeated variable substitution should succeed");
|
||||
assert_eq!(
|
||||
result, r#"{"a": "value", "b": "value", "c": "prefix_value_suffix"}"#,
|
||||
"All occurrences of REPEATED should be replaced with 'value', including within context"
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
cleanup_env_vars(&test_vars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substitute_env_vars_missing_or_empty() {
|
||||
// Test 1: Missing variable
|
||||
// Ensure MISSING_VAR is not set
|
||||
env::remove_var("MISSING_VAR");
|
||||
|
||||
let input = r#"{"key": "${MISSING_VAR}"}"#;
|
||||
let result = substitute_env_vars(input);
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Missing or empty environment variable: MISSING_VAR"),
|
||||
"Error message should mention MISSING_VAR, got: {err_msg}"
|
||||
);
|
||||
|
||||
// Test 2: Empty variable
|
||||
env::set_var("EMPTY_VAR", "");
|
||||
|
||||
let input = r#"{"key": "${EMPTY_VAR}"}"#;
|
||||
let result = substitute_env_vars(input);
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Missing or empty environment variable: EMPTY_VAR"),
|
||||
"Error message should mention EMPTY_VAR, got: {err_msg}"
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
cleanup_env_vars(&["EMPTY_VAR"]);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::ai::mcp::templatable::{
|
||||
GalleryData, JsonTemplate, TemplatableMCPServer, TemplateVariable,
|
||||
};
|
||||
use crate::server::cloud_objects::update_manager::{UpdateManager, UpdateManagerEvent};
|
||||
use crate::server::datetime_ext::DateTimeExt;
|
||||
use chrono::DateTime;
|
||||
use uuid::Uuid;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GalleryMCPServer {
|
||||
uuid: Uuid,
|
||||
title: String,
|
||||
description: String,
|
||||
#[allow(dead_code)]
|
||||
version: i32,
|
||||
#[allow(dead_code)]
|
||||
instructions_in_markdown: Option<String>,
|
||||
json_template: JsonTemplate,
|
||||
}
|
||||
|
||||
impl GalleryMCPServer {
|
||||
pub fn new(
|
||||
uuid: Uuid,
|
||||
title: String,
|
||||
description: String,
|
||||
version: i32,
|
||||
instructions_in_markdown: Option<String>,
|
||||
json_template: JsonTemplate,
|
||||
) -> Self {
|
||||
Self {
|
||||
uuid,
|
||||
title,
|
||||
description,
|
||||
version,
|
||||
instructions_in_markdown,
|
||||
json_template,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uuid(&self) -> Uuid {
|
||||
self.uuid
|
||||
}
|
||||
|
||||
pub fn title(&self) -> String {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
pub fn description(&self) -> String {
|
||||
self.description.clone()
|
||||
}
|
||||
|
||||
pub fn version(&self) -> i32 {
|
||||
self.version
|
||||
}
|
||||
|
||||
pub fn json_template(&self) -> &JsonTemplate {
|
||||
&self.json_template
|
||||
}
|
||||
|
||||
pub fn instructions_in_markdown(&self) -> Option<&String> {
|
||||
self.instructions_in_markdown.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<GalleryMCPServer> for TemplatableMCPServer {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(gallery_server: GalleryMCPServer) -> Result<Self, Self::Error> {
|
||||
let GalleryMCPServer {
|
||||
uuid: gallery_uuid,
|
||||
title,
|
||||
description,
|
||||
version: gallery_version,
|
||||
instructions_in_markdown: _,
|
||||
json_template,
|
||||
} = gallery_server;
|
||||
|
||||
Ok(TemplatableMCPServer {
|
||||
uuid: Uuid::new_v4(),
|
||||
name: title,
|
||||
description: Some(description),
|
||||
template: json_template,
|
||||
version: DateTime::now().timestamp(),
|
||||
gallery_data: Some(GalleryData {
|
||||
gallery_item_id: gallery_uuid,
|
||||
version: gallery_version,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MCPGalleryManager {
|
||||
gallery_items: HashMap<Uuid, GalleryMCPServer>,
|
||||
templatable_mcp_servers: HashMap<Uuid, TemplatableMCPServer>,
|
||||
}
|
||||
|
||||
impl MCPGalleryManager {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let gallery_manager = Self {
|
||||
gallery_items: Default::default(),
|
||||
templatable_mcp_servers: Default::default(),
|
||||
};
|
||||
|
||||
// Subscribe to UpdateManager events to receive MCP gallery updates
|
||||
let update_manager = UpdateManager::handle(ctx);
|
||||
ctx.subscribe_to_model(&update_manager, |me, event, ctx| {
|
||||
if let UpdateManagerEvent::MCPGalleryUpdated { templates } = event {
|
||||
me.update_gallery_items(templates.clone(), ctx);
|
||||
}
|
||||
});
|
||||
|
||||
gallery_manager
|
||||
}
|
||||
|
||||
pub fn get_gallery(&self) -> Vec<GalleryMCPServer> {
|
||||
self.gallery_items.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn get_gallery_item(&self, gallery_uuid: Uuid) -> Option<&GalleryMCPServer> {
|
||||
self.gallery_items.get(&gallery_uuid)
|
||||
}
|
||||
|
||||
pub fn get_templatable_mcp_server(&self, gallery_uuid: Uuid) -> Option<&TemplatableMCPServer> {
|
||||
self.templatable_mcp_servers.get(&gallery_uuid)
|
||||
}
|
||||
|
||||
/// Update gallery items from the server response
|
||||
pub fn update_gallery_items(
|
||||
&mut self,
|
||||
templates: Vec<warp_graphql::mcp_gallery_template::MCPGalleryTemplate>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let mut gallery_items = HashMap::new();
|
||||
let mut templatable_mcp_servers = HashMap::new();
|
||||
|
||||
for gallery_template in templates {
|
||||
let Ok(uuid) = Uuid::parse_str(&gallery_template.gallery_item_id) else {
|
||||
log::debug!(
|
||||
"Failed to parse uuid for gallery item {}",
|
||||
gallery_template.gallery_item_id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let json_template = JsonTemplate {
|
||||
json: gallery_template.json_template.json,
|
||||
variables: gallery_template
|
||||
.json_template
|
||||
.variables
|
||||
.into_iter()
|
||||
.map(|v| TemplateVariable {
|
||||
key: v.key,
|
||||
allowed_values: v.allowed_values,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let gallery_item = GalleryMCPServer::new(
|
||||
uuid,
|
||||
gallery_template.title,
|
||||
gallery_template.description,
|
||||
gallery_template.version,
|
||||
gallery_template.instructions_in_markdown,
|
||||
json_template,
|
||||
);
|
||||
|
||||
let Ok(templatable_mcp_server): Result<TemplatableMCPServer, _> =
|
||||
gallery_item.clone().try_into()
|
||||
else {
|
||||
log::debug!("Failed to parse template for gallery item {}", uuid);
|
||||
continue;
|
||||
};
|
||||
|
||||
gallery_items.insert(uuid, gallery_item);
|
||||
templatable_mcp_servers.insert(uuid, templatable_mcp_server);
|
||||
}
|
||||
|
||||
self.gallery_items = gallery_items;
|
||||
self.templatable_mcp_servers = templatable_mcp_servers;
|
||||
|
||||
ctx.emit(MCPGalleryManagerEvent::ItemsRefreshed);
|
||||
}
|
||||
}
|
||||
|
||||
pub enum MCPGalleryManagerEvent {
|
||||
ItemsRefreshed,
|
||||
}
|
||||
|
||||
impl Entity for MCPGalleryManager {
|
||||
type Event = MCPGalleryManagerEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for MCPGalleryManager {}
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use reqwest::header::HeaderMap;
|
||||
|
||||
type ReqwestHttpTransport = rmcp::transport::StreamableHttpClientTransport<reqwest::Client>;
|
||||
|
||||
/// Builds a `HeaderMap` from a `HashMap<String, String>` of user-provided headers.
|
||||
///
|
||||
/// Invalid header names or values are skipped.
|
||||
fn build_header_map(headers: &HashMap<String, String>) -> HeaderMap {
|
||||
headers.try_into().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Builds a reqwest client with custom headers for MCP HTTP/SSE connections.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn build_client_with_headers(
|
||||
headers: &HashMap<String, String>,
|
||||
) -> Result<reqwest::Client, rmcp::RmcpError> {
|
||||
let header_map = build_header_map(headers);
|
||||
|
||||
reqwest::Client::builder()
|
||||
.default_headers(header_map)
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>(format!(
|
||||
"Failed to build client with headers: {e}",
|
||||
))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use simple_logger::manager::resolve_log_path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn relative_log_file_path_from_uuid(uuid: &Uuid) -> PathBuf {
|
||||
PathBuf::from(format!("{uuid}.log"))
|
||||
}
|
||||
|
||||
/// The path to the file where an MCP server's log gets written.
|
||||
pub fn log_file_path_from_uuid(uuid: &Uuid) -> PathBuf {
|
||||
resolve_log_path("mcp", relative_log_file_path_from_uuid(uuid))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// Minimal stub of the former legacy MCP server manager module.
|
||||
///
|
||||
/// The only thing still needed from this module is the legacy secure-storage
|
||||
/// key constant in `oauth`, which is read during the one-time migration of
|
||||
/// legacy MCP servers to the templatable model.
|
||||
pub mod oauth;
|
||||
@@ -0,0 +1,7 @@
|
||||
/// Secure storage key for legacy MCP server OAuth credentials.
|
||||
///
|
||||
/// Kept so that `TemplatableMCPServerManager::copy_oauth_from_legacy_to_templatable`
|
||||
/// can read credentials stored by the (now-removed) legacy `MCPServerManager` and
|
||||
/// migrate them into the templatable credential store.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub const LEGACY_MCP_CREDENTIALS_KEY: &str = "McpCredentials";
|
||||
@@ -0,0 +1,713 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::server::datetime_ext::DateTimeExt;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use chrono::DateTime;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::persistence::model::MCPEnvironmentVariables;
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::{
|
||||
generic_string_model::{GenericStringModel, GenericStringObjectId, StringModel},
|
||||
json_model::{JsonModel, JsonSerializer},
|
||||
persistence::CloudModel,
|
||||
},
|
||||
GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey,
|
||||
JsonObjectType, Revision, ServerCloudObject,
|
||||
},
|
||||
drive::{
|
||||
items::{mcp_server::WarpDriveMCPServer, WarpDriveItem},
|
||||
CloudObjectTypeAndId,
|
||||
},
|
||||
server::{ids::SyncId, sync_queue::QueueItem},
|
||||
};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use diesel::{QueryDsl, RunQueryDsl, SqliteConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::EnumIter;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::Icon;
|
||||
|
||||
pub mod manager;
|
||||
pub mod templatable_manager;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use templatable_manager::McpIntegration;
|
||||
pub use templatable_manager::TemplatableMCPServerManager;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(not(feature = "local_fs"))] {
|
||||
mod dummy_file_based_manager;
|
||||
pub use dummy_file_based_manager::FileBasedMCPManager;
|
||||
mod dummy_file_mcp_watcher;
|
||||
pub use dummy_file_mcp_watcher::FileMCPWatcher;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn home_config_file_path(provider: MCPProvider) -> Option<PathBuf> {
|
||||
match provider {
|
||||
MCPProvider::Warp => warp_core::paths::warp_home_mcp_config_file_path(),
|
||||
_ => dirs::home_dir().map(|home_dir| home_dir.join(provider.home_config_path())),
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
pub mod file_based_manager;
|
||||
pub use file_based_manager::FileBasedMCPManager;
|
||||
pub mod file_mcp_watcher;
|
||||
pub use file_mcp_watcher::{FileMCPWatcher, FileMCPWatcherEvent};
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)]
|
||||
pub enum MCPProvider {
|
||||
Warp,
|
||||
Claude,
|
||||
Codex,
|
||||
Agents,
|
||||
}
|
||||
|
||||
impl MCPProvider {
|
||||
pub fn display_name(&self) -> &str {
|
||||
match self {
|
||||
MCPProvider::Warp => "Warp",
|
||||
MCPProvider::Claude => "Claude",
|
||||
MCPProvider::Codex => "Codex",
|
||||
MCPProvider::Agents => "Other Agents",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(&self) -> Icon {
|
||||
match self {
|
||||
MCPProvider::Warp => Icon::Warp,
|
||||
MCPProvider::Claude => Icon::ClaudeLogo,
|
||||
MCPProvider::Codex => Icon::OpenAILogo,
|
||||
MCPProvider::Agents => Icon::Warp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the path of the provider's config file relative to the home directory.
|
||||
pub fn home_config_path(&self) -> &'static Path {
|
||||
match self {
|
||||
MCPProvider::Warp => Path::new(".warp/.mcp.json"),
|
||||
MCPProvider::Claude => Path::new(".claude.json"),
|
||||
MCPProvider::Codex => Path::new(".codex/config.toml"),
|
||||
MCPProvider::Agents => Path::new(".agents/.mcp.json"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the path of the provider's config file relative to a project root.
|
||||
pub fn project_config_path(&self) -> &'static Path {
|
||||
match self {
|
||||
MCPProvider::Warp => Path::new(".warp/.mcp.json"),
|
||||
MCPProvider::Claude => Path::new(".mcp.json"),
|
||||
MCPProvider::Codex => Path::new(".codex/config.toml"),
|
||||
MCPProvider::Agents => Path::new(".agents/.mcp.json"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [`MCPProvider`] that owns `file_path` as a config file, if any.
|
||||
///
|
||||
/// Matches against both home-level configs (e.g. `~/.claude.json`) and
|
||||
/// project-level configs (e.g. `.mcp.json` anywhere in the path).
|
||||
pub fn mcp_provider_from_file_path(file_path: &Path) -> Option<MCPProvider> {
|
||||
// Try exact home-config match first (unambiguous).
|
||||
for provider in MCPProvider::iter() {
|
||||
if home_config_file_path(provider)
|
||||
.as_ref()
|
||||
.is_some_and(|home_config_path| file_path == home_config_path)
|
||||
{
|
||||
return Some(provider);
|
||||
}
|
||||
}
|
||||
// Fall back to project-config suffix match, preferring the longest
|
||||
// (most-specific) suffix.
|
||||
// This avoids `.mcp.json` shadowing `.warp/.mcp.json`, for example.
|
||||
let mut best: Option<(MCPProvider, usize)> = None;
|
||||
for provider in MCPProvider::iter() {
|
||||
let cfg = provider.project_config_path();
|
||||
if file_path.ends_with(cfg) {
|
||||
let len = cfg.as_os_str().len();
|
||||
if best.is_none_or(|(_, best_len)| len > best_len) {
|
||||
best = Some((provider, len));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{mcp_provider_from_file_path, MCPProvider};
|
||||
|
||||
#[test]
|
||||
fn mcp_provider_from_file_path_recognizes_warp_home_path() {
|
||||
if let Some(warp_home_mcp_config_file_path) =
|
||||
warp_core::paths::warp_home_mcp_config_file_path()
|
||||
{
|
||||
assert_eq!(
|
||||
mcp_provider_from_file_path(&warp_home_mcp_config_file_path),
|
||||
Some(MCPProvider::Warp)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod gallery;
|
||||
pub use gallery::MCPGalleryManager;
|
||||
use warpui::{AppContext, SingletonEntity as _};
|
||||
pub mod templatable;
|
||||
pub use templatable::JsonTemplate;
|
||||
pub use templatable::{TemplatableMCPServer, TemplateVariable};
|
||||
pub mod logs;
|
||||
pub mod templatable_installation;
|
||||
pub use templatable_installation::TemplatableMCPServerInstallation;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use templatable_installation::{VariableType, VariableValue};
|
||||
pub mod parsing;
|
||||
pub use parsing::ParsedTemplatableMCPServerResult;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod http_client;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod reconnecting_peer;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(target_family = "wasm", expect(dead_code))]
|
||||
pub struct JSONMCPServer {
|
||||
#[serde(flatten)]
|
||||
pub transport_type: JSONTransportType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum JSONTransportType {
|
||||
CLIServer {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
working_directory: Option<String>,
|
||||
},
|
||||
SSEServer {
|
||||
#[serde(alias = "serverUrl")]
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MCPServer {
|
||||
pub transport_type: TransportType,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub uuid: uuid::Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub enum MCPServerState {
|
||||
NotRunning,
|
||||
Starting,
|
||||
Authenticating,
|
||||
Running,
|
||||
ShuttingDown,
|
||||
FailedToStart,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransportType {
|
||||
CLIServer(CLIServer),
|
||||
ServerSentEvents(ServerSentEvents),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CLIServer {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
pub cwd_parameter: Option<String>,
|
||||
/// Static env vars added via editor inputs.
|
||||
pub static_env_vars: Vec<StaticEnvVar>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StaticEnvVar {
|
||||
pub name: String,
|
||||
/// To avoid leaking environment variables, we ensure that values are not
|
||||
/// serialized before being sent to our servers
|
||||
#[serde(skip_serializing, default)]
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StaticHeader {
|
||||
pub name: String,
|
||||
/// To avoid leaking header values (which may contain secrets), we ensure that values are not
|
||||
/// serialized before being sent to our servers
|
||||
#[serde(skip_serializing, default)]
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerSentEvents {
|
||||
pub url: String,
|
||||
/// Static headers added via editor inputs.
|
||||
#[serde(default)]
|
||||
pub headers: Vec<StaticHeader>,
|
||||
}
|
||||
|
||||
pub type CloudMCPServer = GenericCloudObject<GenericStringObjectId, CloudMCPServerModel>;
|
||||
pub type CloudMCPServerModel = GenericStringModel<MCPServer, JsonSerializer>;
|
||||
|
||||
impl CloudMCPServer {
|
||||
pub fn get_all(app: &AppContext) -> Vec<CloudMCPServer> {
|
||||
CloudModel::as_ref(app)
|
||||
.get_all_objects_of_type::<GenericStringObjectId, CloudMCPServerModel>()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_by_id<'a>(sync_id: &'a SyncId, app: &'a AppContext) -> Option<&'a CloudMCPServer> {
|
||||
CloudModel::as_ref(app)
|
||||
.get_object_of_type::<GenericStringObjectId, CloudMCPServerModel>(sync_id)
|
||||
}
|
||||
|
||||
pub fn get_by_uuid<'a>(
|
||||
uuid: &'a uuid::Uuid,
|
||||
app: &'a AppContext,
|
||||
) -> Option<&'a CloudMCPServer> {
|
||||
CloudModel::as_ref(app)
|
||||
.get_all_objects_of_type::<GenericStringObjectId, CloudMCPServerModel>()
|
||||
.find(|server| server.model().string_model.uuid == *uuid)
|
||||
}
|
||||
}
|
||||
|
||||
impl StringModel for MCPServer {
|
||||
type CloudObjectType = CloudMCPServer;
|
||||
|
||||
fn model_type_name(&self) -> &'static str {
|
||||
"MCP server"
|
||||
}
|
||||
|
||||
fn should_enforce_revisions() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn model_format() -> GenericStringObjectFormat {
|
||||
GenericStringObjectFormat::Json(JsonObjectType::MCPServer)
|
||||
}
|
||||
|
||||
fn should_show_activity_toasts() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn warn_if_unsaved_at_quit() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn update_object_queue_item(
|
||||
&self,
|
||||
revision_ts: Option<Revision>,
|
||||
object: &Self::CloudObjectType,
|
||||
) -> QueueItem {
|
||||
QueueItem::UpdateMCPServer {
|
||||
model: object.model().clone().into(),
|
||||
id: object.id,
|
||||
revision: revision_ts.or_else(|| object.metadata.revision.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
|
||||
if let ServerCloudObject::MCPServer(server_mcp_server) = server_cloud_object {
|
||||
return Some(server_mcp_server.model.clone().string_model);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn uniqueness_key(&self) -> Option<GenericStringObjectUniqueKey> {
|
||||
None
|
||||
}
|
||||
|
||||
fn renders_in_warp_drive(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn to_warp_drive_item(
|
||||
&self,
|
||||
id: SyncId,
|
||||
_appearance: &Appearance,
|
||||
mcp_server: &CloudMCPServer,
|
||||
) -> Option<Box<dyn WarpDriveItem>> {
|
||||
Some(Box::new(WarpDriveMCPServer::new(
|
||||
CloudObjectTypeAndId::GenericStringObject {
|
||||
object_type: GenericStringObjectFormat::Json(JsonObjectType::MCPServer),
|
||||
id,
|
||||
},
|
||||
mcp_server.clone(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for MCPServer {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::MCPServer
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for types that have a name and value field.
|
||||
/// Used for shared operations on `StaticEnvVar` and `StaticHeader`.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
trait NameValuePair {
|
||||
fn name(&self) -> &str;
|
||||
fn value(&self) -> &str;
|
||||
fn new(name: String, value: String) -> Self;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn set_value(&mut self, value: String);
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl NameValuePair for StaticEnvVar {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
fn new(name: String, value: String) -> Self {
|
||||
Self { name, value }
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn set_value(&mut self, value: String) {
|
||||
self.value = value;
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl NameValuePair for StaticHeader {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
fn new(name: String, value: String) -> Self {
|
||||
Self { name, value }
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn set_value(&mut self, value: String) {
|
||||
self.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a HashMap to a Vec of name/value pair items.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn items_from_hashmap<T: NameValuePair>(map: &HashMap<String, String>) -> Vec<T> {
|
||||
map.iter()
|
||||
.map(|(name, value)| T::new(name.to_owned(), value.to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Converts a slice of name/value pair items to a HashMap.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn items_to_hashmap<T: NameValuePair>(items: &[T]) -> HashMap<String, String> {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| (item.name().to_owned(), item.value().to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Processes name/value pair items for template conversion.
|
||||
/// Returns a tuple of:
|
||||
/// - HashMap with template placeholders (e.g., `{{name}}`)
|
||||
/// - Vec of TemplateVariables
|
||||
/// - HashMap of VariableValues
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn extract_template_variables<T: NameValuePair>(
|
||||
items: &[T],
|
||||
) -> (
|
||||
HashMap<String, String>,
|
||||
Vec<TemplateVariable>,
|
||||
HashMap<String, VariableValue>,
|
||||
) {
|
||||
let mut template_map = HashMap::new();
|
||||
let mut variables = Vec::new();
|
||||
let mut variable_values = HashMap::new();
|
||||
|
||||
for item in items {
|
||||
let name = item.name().to_owned();
|
||||
// Map the name to {{name}} template placeholder
|
||||
template_map.insert(name.clone(), format!("{{{{{name}}}}}"));
|
||||
variables.push(TemplateVariable {
|
||||
key: name.clone(),
|
||||
allowed_values: None,
|
||||
});
|
||||
variable_values.insert(
|
||||
name,
|
||||
VariableValue {
|
||||
variable_type: VariableType::Text,
|
||||
value: item.value().to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
(template_map, variables, variable_values)
|
||||
}
|
||||
|
||||
/// Applies values from a persisted HashMap to a collection of name/value pairs.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn apply_values<T: NameValuePair>(items: &mut [T], values: &HashMap<String, String>) {
|
||||
for item in items.iter_mut() {
|
||||
if let Some(value) = values.get(item.name()) {
|
||||
item.set_value(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl MCPServer {
|
||||
fn find_server_map(
|
||||
config: serde_json::Value,
|
||||
) -> serde_json::Result<HashMap<String, JSONMCPServer>> {
|
||||
// We want to be quite permissive in parsing user input. They may specify more than one
|
||||
// server. They might paste things in Claude Desktop style or VSCode style. All are
|
||||
// accepted here.
|
||||
//
|
||||
// VSCode:
|
||||
// {
|
||||
// "mcp": {
|
||||
// "servers": {
|
||||
// [map of mcp servers]
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// --- OR ---
|
||||
// {
|
||||
// "servers": {
|
||||
// [map of mcp servers]
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Claude Desktop:
|
||||
// {
|
||||
// "mcpServers": {
|
||||
// [map of mcp servers]
|
||||
// }
|
||||
// }
|
||||
// Also allowed:
|
||||
// {
|
||||
// [map of mcp servers]
|
||||
// }
|
||||
|
||||
let pointers = ["/mcp/servers", "/servers", "/mcpServers"];
|
||||
for pointer in pointers.into_iter() {
|
||||
if let Some(value) = config.pointer(pointer) {
|
||||
if let Ok(servers) =
|
||||
serde_json::from_value::<HashMap<String, JSONMCPServer>>(value.clone())
|
||||
{
|
||||
return Ok(servers);
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::from_value::<HashMap<String, JSONMCPServer>>(config)
|
||||
}
|
||||
pub fn from_user_json(json: &str) -> serde_json::Result<Vec<MCPServer>> {
|
||||
// Some docs don't show curly braces around the json object, so add them if necessary.
|
||||
let json = json.trim();
|
||||
let json = if json.starts_with("{") {
|
||||
json.to_owned()
|
||||
} else {
|
||||
format!("{{{json}}}")
|
||||
};
|
||||
|
||||
let config: serde_json::Value = serde_json::from_str(&json)?;
|
||||
|
||||
let servers = Self::find_server_map(config)?;
|
||||
Ok(servers
|
||||
.iter()
|
||||
.map(|(name, server)| {
|
||||
let transport_type = match &server.transport_type {
|
||||
JSONTransportType::CLIServer {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
working_directory,
|
||||
} => TransportType::CLIServer(CLIServer {
|
||||
command: command.clone(),
|
||||
args: args.clone(),
|
||||
cwd_parameter: working_directory.to_owned(),
|
||||
static_env_vars: items_from_hashmap(env),
|
||||
}),
|
||||
JSONTransportType::SSEServer { url, headers } => {
|
||||
TransportType::ServerSentEvents(ServerSentEvents {
|
||||
url: url.to_owned(),
|
||||
headers: items_from_hashmap(headers),
|
||||
})
|
||||
}
|
||||
};
|
||||
MCPServer {
|
||||
name: name.to_owned(),
|
||||
transport_type,
|
||||
uuid: uuid::Uuid::new_v4(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Includes the environment variable values, should only be shown to users,
|
||||
/// not sent to our servers.
|
||||
pub fn to_user_json(&self) -> String {
|
||||
let transport_type = match &self.transport_type {
|
||||
TransportType::CLIServer(cli_server) => JSONTransportType::CLIServer {
|
||||
command: cli_server.command.clone(),
|
||||
args: cli_server.args.clone(),
|
||||
env: items_to_hashmap(&cli_server.static_env_vars),
|
||||
working_directory: cli_server.cwd_parameter.to_owned(),
|
||||
},
|
||||
TransportType::ServerSentEvents(sse_server) => JSONTransportType::SSEServer {
|
||||
url: sse_server.url.to_owned(),
|
||||
headers: items_to_hashmap(&sse_server.headers),
|
||||
},
|
||||
};
|
||||
serde_json::to_string_pretty(
|
||||
&std::iter::once((self.name.to_owned(), JSONMCPServer { transport_type }))
|
||||
.collect::<HashMap<_, _>>(),
|
||||
)
|
||||
// serde_json::to_string_pretty should never fail on our JSONMCPServer type, but better to
|
||||
// not crash the app if it does.
|
||||
.unwrap_or_else(|err| {
|
||||
log::error!("Could not serialize MCP server to user json: {err:?}");
|
||||
Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_parsed_templatable_mcp_server_result(&self) -> ParsedTemplatableMCPServerResult {
|
||||
let (transport_type, variables, variable_values) = match &self.transport_type {
|
||||
TransportType::CLIServer(cli_server) => {
|
||||
let (env, vars, vals) = extract_template_variables(&cli_server.static_env_vars);
|
||||
(
|
||||
JSONTransportType::CLIServer {
|
||||
command: cli_server.command.clone(),
|
||||
args: cli_server.args.clone(),
|
||||
env,
|
||||
working_directory: cli_server.cwd_parameter.to_owned(),
|
||||
},
|
||||
vars,
|
||||
vals,
|
||||
)
|
||||
}
|
||||
TransportType::ServerSentEvents(sse_server) => {
|
||||
let (headers, vars, vals) = extract_template_variables(&sse_server.headers);
|
||||
(
|
||||
JSONTransportType::SSEServer {
|
||||
url: sse_server.url.to_owned(),
|
||||
headers,
|
||||
},
|
||||
vars,
|
||||
vals,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(
|
||||
&std::iter::once((self.name.to_owned(), JSONMCPServer { transport_type }))
|
||||
.collect::<HashMap<_, _>>(),
|
||||
)
|
||||
// serde_json::to_string_pretty should never fail on our JSONMCPServer type, but better to
|
||||
// not crash the app if it does.
|
||||
.unwrap_or_else(|err| {
|
||||
log::error!("Could not serialize MCP server to user json: {err:?}");
|
||||
Default::default()
|
||||
});
|
||||
|
||||
let templatable_mcp_server = TemplatableMCPServer {
|
||||
uuid: self.uuid, // UUIDs must be preserved so we can match legacy and (shared) templatable MCP servers
|
||||
name: self.name.clone(),
|
||||
description: None,
|
||||
template: JsonTemplate { json, variables },
|
||||
version: DateTime::now().timestamp(),
|
||||
gallery_data: None,
|
||||
};
|
||||
let templatable_mcp_server_installation: Option<TemplatableMCPServerInstallation> =
|
||||
Some(TemplatableMCPServerInstallation::new(
|
||||
uuid::Uuid::new_v4(),
|
||||
templatable_mcp_server.clone(),
|
||||
variable_values,
|
||||
));
|
||||
|
||||
ParsedTemplatableMCPServerResult {
|
||||
templatable_mcp_server,
|
||||
templatable_mcp_server_installation,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fill_environment_variables(&mut self, conn: &mut SqliteConnection) {
|
||||
if let TransportType::CLIServer(ref mut cli_server) = self.transport_type {
|
||||
let uuid = self.uuid.as_bytes().to_vec();
|
||||
match crate::persistence::schema::mcp_environment_variables::dsl::mcp_environment_variables
|
||||
.find(uuid)
|
||||
.first::<MCPEnvironmentVariables>(conn)
|
||||
{
|
||||
Ok(mcp_env_vars) => {
|
||||
let env_vars: HashMap<String, String> =
|
||||
serde_json::from_str(&mcp_env_vars.environment_variables).unwrap();
|
||||
apply_values(&mut cli_server.static_env_vars, &env_vars);
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Could not read MCP server environment variables from sqlite: {error:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
impl MCPServer {
|
||||
pub fn from_user_json(_json: &str) -> serde_json::Result<Vec<MCPServer>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn to_user_json(&self) -> String {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
pub fn to_parsed_templatable_mcp_server_result(&self) -> ParsedTemplatableMCPServerResult {
|
||||
ParsedTemplatableMCPServerResult {
|
||||
templatable_mcp_server: TemplatableMCPServer::default(),
|
||||
templatable_mcp_server_installation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Author {
|
||||
CurrentUser,
|
||||
OtherUser { name: String },
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MCPServerUpdate {
|
||||
CloudTemplate {
|
||||
publisher: Author,
|
||||
new_version_ts: i64,
|
||||
json_template: JsonTemplate,
|
||||
},
|
||||
Gallery {
|
||||
name: String,
|
||||
new_version: i32,
|
||||
json_template: JsonTemplate,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mod_test;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,406 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::DateTime;
|
||||
use handlebars::{get_arguments, render_template};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use serde::Deserialize;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::ai::mcp::{JSONMCPServer, JSONTransportType};
|
||||
|
||||
use crate::{
|
||||
ai::mcp::{
|
||||
templatable::{JsonTemplate, TemplatableMCPServer, TemplateVariable},
|
||||
templatable_installation::{TemplatableMCPServerInstallation, VariableType, VariableValue},
|
||||
},
|
||||
server::datetime_ext::DateTimeExt,
|
||||
};
|
||||
|
||||
/// Normalize MCP JSON input to ensure it has a server name wrapper.
|
||||
///
|
||||
/// If the JSON is a single server definition (has `command` or `url` at the top level),
|
||||
/// wrap it with a generated name. Otherwise, return the JSON as-is.
|
||||
///
|
||||
/// Note: When returning the original JSON, we preserve the exact input string.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) fn normalize_mcp_json(json_str: &str) -> serde_json::Result<String> {
|
||||
// Some docs don't show curly braces around the json object, so add them if necessary.
|
||||
let json = json_str.trim();
|
||||
let json_for_parsing = if json.starts_with('{') {
|
||||
json.to_owned()
|
||||
} else {
|
||||
format!("{{{json}}}")
|
||||
};
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(&json_for_parsing)?;
|
||||
|
||||
// Check if this is a single server definition (has command or url at top level)
|
||||
let is_single_server = value.get("command").is_some() || value.get("url").is_some();
|
||||
|
||||
if is_single_server {
|
||||
let server_name = uuid::Uuid::new_v4().to_string();
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert(server_name, value);
|
||||
Ok(serde_json::Value::Object(map).to_string())
|
||||
} else {
|
||||
Ok(json_str.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A single entry under `[mcp_servers.<name>]` in a Codex TOML file.
|
||||
///
|
||||
/// Codex servers are either STDIO (discriminated by a required `command` field)
|
||||
/// or streamable HTTP (discriminated by a required `url` field).
|
||||
/// See https://developers.openai.com/codex/mcp/ for more details on Codex's MCP configuration spec.
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum CodexServerEntry {
|
||||
/// A local process server launched over stdin/stdout.
|
||||
Stdio {
|
||||
/// The command that starts the server. Acts as the discriminant.
|
||||
command: String,
|
||||
/// Arguments to pass to the server command.
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
/// Literal key=value environment variables set on the server process.
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
/// Names of environment variables to forward from the calling shell.
|
||||
/// Each name `N` is lowered to a `"${N}"` placeholder in the merged `env` map.
|
||||
/// Explicit `env` values win over `env_vars` placeholders on collision.
|
||||
#[serde(default)]
|
||||
env_vars: Vec<String>,
|
||||
/// Working directory for the server process.
|
||||
/// Mapped to `working_directory` in Warp JSON.
|
||||
cwd: Option<String>,
|
||||
},
|
||||
/// A remote server reached over streamable HTTP.
|
||||
Http {
|
||||
/// The server URL. Acts as the discriminant.
|
||||
url: String,
|
||||
/// Name of an environment variable holding a bearer token.
|
||||
/// Lowered to `Authorization: "Bearer ${VAR}"` in the headers map.
|
||||
bearer_token_env_var: Option<String>,
|
||||
/// Static header values sent verbatim (e.g. `X-Client = "codex"`).
|
||||
/// Wins over `env_http_headers` on collision.
|
||||
#[serde(default)]
|
||||
http_headers: HashMap<String, String>,
|
||||
/// Map of header names to environment variable names.
|
||||
/// Each entry becomes `header = "${var_name}"` in the headers map.
|
||||
#[serde(default)]
|
||||
env_http_headers: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl From<CodexServerEntry> for JSONTransportType {
|
||||
fn from(entry: CodexServerEntry) -> Self {
|
||||
match entry {
|
||||
CodexServerEntry::Stdio {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
} => {
|
||||
// Build merged env: env_vars placeholders first.
|
||||
let mut merged_env: HashMap<String, String> = env_vars
|
||||
.into_iter()
|
||||
.map(|var_name| (var_name.clone(), format!("${{{var_name}}}")))
|
||||
.collect();
|
||||
// Explicit env wins over env_vars placeholders on collision.
|
||||
for (k, v) in env {
|
||||
merged_env.insert(k, v);
|
||||
}
|
||||
|
||||
JSONTransportType::CLIServer {
|
||||
command,
|
||||
args,
|
||||
env: merged_env,
|
||||
working_directory: cwd,
|
||||
}
|
||||
}
|
||||
CodexServerEntry::Http {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
} => {
|
||||
// Build merged headers.
|
||||
// Merge order (later wins): bearer < env_http_headers < http_headers.
|
||||
let mut merged_headers: HashMap<String, String> = HashMap::new();
|
||||
|
||||
if let Some(var_name) = bearer_token_env_var {
|
||||
merged_headers.insert(
|
||||
"Authorization".to_owned(),
|
||||
format!("Bearer ${{{var_name}}}"),
|
||||
);
|
||||
}
|
||||
for (header, var_name) in env_http_headers {
|
||||
merged_headers.insert(header, format!("${{{var_name}}}"));
|
||||
}
|
||||
for (header, value) in http_headers {
|
||||
merged_headers.insert(header, value);
|
||||
}
|
||||
|
||||
JSONTransportType::SSEServer {
|
||||
url,
|
||||
headers: merged_headers,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes the contents of a Codex `config.toml` into a JSON string
|
||||
/// compatible with `ParsedTemplatableMCPServerResult::from_user_json`.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn normalize_codex_toml_to_json(file_contents: &str) -> Result<String, anyhow::Error> {
|
||||
// Parse into a raw Value first so we can handle per-entry deserialization failures
|
||||
// gracefully. Using HashMap<String, CodexServerEntry> directly would cause the entire
|
||||
// parse to fail if any single entry matches neither Stdio nor Http.
|
||||
let raw: toml::Value = toml::from_str(file_contents)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse Codex TOML: {e}"))?;
|
||||
|
||||
let out_servers: HashMap<String, JSONMCPServer> = raw
|
||||
.get("mcp_servers")
|
||||
.and_then(|v| v.as_table())
|
||||
.map(|table| {
|
||||
table
|
||||
.iter()
|
||||
.filter_map(|(name, val)| {
|
||||
val.clone()
|
||||
.try_into::<CodexServerEntry>()
|
||||
.ok()
|
||||
.map(|entry| {
|
||||
(
|
||||
name.clone(),
|
||||
JSONMCPServer {
|
||||
transport_type: JSONTransportType::from(entry),
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let wrapped = serde_json::json!({ "mcp_servers": out_servers });
|
||||
serde_json::to_string(&wrapped)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to serialize normalized Codex TOML as JSON: {e}"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedTemplatableMCPServerResult {
|
||||
pub templatable_mcp_server: TemplatableMCPServer,
|
||||
pub templatable_mcp_server_installation: Option<TemplatableMCPServerInstallation>,
|
||||
}
|
||||
|
||||
/// Extracts a field from JSON as a HashMap<String, String>.
|
||||
fn extract_string_map(
|
||||
json_value: &serde_json::Value,
|
||||
field_name: &str,
|
||||
) -> Option<HashMap<String, String>> {
|
||||
json_value
|
||||
.get(field_name)
|
||||
.and_then(|value| value.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(key, value)| value.as_str().map(|s| (key.to_owned(), s.to_owned())))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts a HashMap's keys to template placeholders (e.g., `{{key}}`) and
|
||||
/// inserts the result back into the JSON value under the given field name.
|
||||
fn templatize_field(
|
||||
json_value: &mut serde_json::Value,
|
||||
field_name: &str,
|
||||
map: &HashMap<String, String>,
|
||||
) {
|
||||
let templated_value = serde_json::Value::Object(
|
||||
map.keys()
|
||||
.map(|key| {
|
||||
(
|
||||
key.to_owned(),
|
||||
serde_json::Value::String(format!("{{{{{key}}}}}")),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if let Some(object) = json_value.as_object_mut() {
|
||||
object.insert(field_name.to_owned(), templated_value);
|
||||
}
|
||||
}
|
||||
|
||||
impl ParsedTemplatableMCPServerResult {
|
||||
/// Parses MCP servers from a config file (e.g. `~/.claude.json`, `.mcp.json`).
|
||||
///
|
||||
/// Unlike [`from_user_json`], this only recognises servers under a known
|
||||
/// wrapper key (`mcpServers`, `servers`, etc.) and will **not** fall back to
|
||||
/// treating every top-level key as a server name.
|
||||
pub fn from_config_file_json(json: &str) -> serde_json::Result<Vec<Self>> {
|
||||
let json = json.trim();
|
||||
let json = if json.starts_with('{') {
|
||||
json.to_owned()
|
||||
} else {
|
||||
format!("{{{json}}}")
|
||||
};
|
||||
|
||||
let config: serde_json::Value = serde_json::from_str(&json)?;
|
||||
let template_jsons = TemplatableMCPServer::find_template_map_strict(&config);
|
||||
|
||||
Ok(template_jsons
|
||||
.iter()
|
||||
.map(|(name, json)| Self::parse_result(name, json))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Parses the user json and returns a vector of ParsedTemplatableMCPServerResult
|
||||
/// If the json is invalid, returns an error
|
||||
/// It's up to the caller to handle cases where there are an unexpected number of servers
|
||||
pub fn from_user_json(json: &str) -> serde_json::Result<Vec<Self>> {
|
||||
// Some docs don't show curly braces around the json object, so add them if necessary.
|
||||
let json = json.trim();
|
||||
let json = if json.starts_with('{') {
|
||||
json.to_owned()
|
||||
} else {
|
||||
format!("{{{json}}}")
|
||||
};
|
||||
|
||||
let config: serde_json::Value = serde_json::from_str(&json)?;
|
||||
let template_jsons = TemplatableMCPServer::find_template_map(config)?;
|
||||
|
||||
Ok(template_jsons
|
||||
.iter()
|
||||
.map(|(name, json)| Self::parse_result(name, json))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Parses a single MCP config
|
||||
/// Returns a ParsedTemplatableMCPServerResult
|
||||
/// If the json is invalid, returns an error
|
||||
pub(crate) fn parse_result(name: &str, json_value: &serde_json::Value) -> Self {
|
||||
// We need to clone the json value to avoid modifying the original value
|
||||
// json_value needs to be mutable to redact the env/header values
|
||||
let mut json_value = json_value.clone();
|
||||
|
||||
let description: Option<String> = json_value
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str().map(|s| s.to_owned()));
|
||||
|
||||
// Extract env and headers, then convert their values to template placeholders
|
||||
let env = extract_string_map(&json_value, "env");
|
||||
if let Some(ref env) = env {
|
||||
templatize_field(&mut json_value, "env", env);
|
||||
}
|
||||
|
||||
let headers = extract_string_map(&json_value, "headers");
|
||||
if let Some(ref headers) = headers {
|
||||
templatize_field(&mut json_value, "headers", headers);
|
||||
}
|
||||
|
||||
let raw_json = json_value.to_string();
|
||||
let arguments = get_arguments(&raw_json);
|
||||
let variables = arguments
|
||||
.iter()
|
||||
.map(|argument| TemplateVariable {
|
||||
key: argument.clone(),
|
||||
allowed_values: None,
|
||||
})
|
||||
.collect::<Vec<TemplateVariable>>();
|
||||
|
||||
// Each template_json is the nested config for a single MCP server
|
||||
// We need to re-wrap it in a top level object so that we can
|
||||
// reuse from_user_json to read it later
|
||||
let normalized_map =
|
||||
serde_json::Map::from_iter(vec![(name.to_owned(), json_value.clone())]);
|
||||
let normalized_value = serde_json::Value::Object(normalized_map);
|
||||
let normalized_json =
|
||||
serde_json::to_string_pretty(&normalized_value).unwrap_or(normalized_value.to_string());
|
||||
|
||||
let templatable_mcp_server = TemplatableMCPServer {
|
||||
uuid: uuid::Uuid::new_v4(),
|
||||
name: name.to_owned(),
|
||||
description,
|
||||
template: JsonTemplate {
|
||||
json: normalized_json,
|
||||
variables,
|
||||
},
|
||||
version: DateTime::now().timestamp(),
|
||||
gallery_data: None,
|
||||
};
|
||||
|
||||
// Combine env and headers into a single map for variable lookup
|
||||
let combined_values: HashMap<String, String> = env
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(headers.clone().unwrap_or_default())
|
||||
.collect();
|
||||
|
||||
// determine if all variables are present in env or headers
|
||||
let all_variables_present = templatable_mcp_server
|
||||
.template
|
||||
.variables
|
||||
.iter()
|
||||
.all(|variable| combined_values.contains_key(&variable.key));
|
||||
|
||||
let templatable_mcp_server_installation = match all_variables_present {
|
||||
true => {
|
||||
let variable_values = combined_values
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key,
|
||||
VariableValue {
|
||||
variable_type: VariableType::Text,
|
||||
value,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(TemplatableMCPServerInstallation::new(
|
||||
uuid::Uuid::new_v4(),
|
||||
templatable_mcp_server.clone(),
|
||||
variable_values,
|
||||
))
|
||||
}
|
||||
false => None,
|
||||
};
|
||||
|
||||
ParsedTemplatableMCPServerResult {
|
||||
templatable_mcp_server,
|
||||
templatable_mcp_server_installation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_json(installation: &TemplatableMCPServerInstallation) -> String {
|
||||
// Collapse the variable values into a flat hashmap for easy replacement
|
||||
fn variable_value_to_string(variable_value: &VariableValue) -> String {
|
||||
match variable_value.variable_type {
|
||||
VariableType::Text => variable_value.value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
let variable_values_strings: HashMap<String, String> = installation
|
||||
.variable_values()
|
||||
.iter()
|
||||
.map(|(key, variable_value)| (key.clone(), variable_value_to_string(variable_value)))
|
||||
.collect();
|
||||
|
||||
render_template(installation.template_json(), &variable_values_strings)
|
||||
}
|
||||
|
||||
pub fn prettify_json(json: &str) -> String {
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
|
||||
serde_json::to_string_pretty(&value).unwrap_or(json.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parsing_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,90 @@
|
||||
use super::ParsedTemplatableMCPServerResult;
|
||||
|
||||
#[test]
|
||||
fn config_file_json_ignores_unrelated_settings() {
|
||||
// ~/.claude.json contains Claude Code app settings, not MCP servers.
|
||||
let claude_code_settings = r#"{
|
||||
"numStartups": 37,
|
||||
"tipsHistory": { "new-user-warmup": 9 },
|
||||
"projects": {},
|
||||
"officialMarketplaceAutoInstallAttempted": true,
|
||||
"sonnet45MigrationComplete": true
|
||||
}"#;
|
||||
|
||||
let servers = ParsedTemplatableMCPServerResult::from_config_file_json(claude_code_settings)
|
||||
.expect("valid JSON should not error");
|
||||
assert!(
|
||||
servers.is_empty(),
|
||||
"Claude Code settings should not be parsed as MCP servers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_json_parses_mcp_servers_key() {
|
||||
let json = r#"{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let servers = ParsedTemplatableMCPServerResult::from_config_file_json(json)
|
||||
.expect("valid JSON should not error");
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].templatable_mcp_server.name, "github");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_json_parses_mcp_dot_servers_key() {
|
||||
let json = r#"{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"my-server": { "command": "uvx", "args": ["mcp-server"] }
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let servers = ParsedTemplatableMCPServerResult::from_config_file_json(json)
|
||||
.expect("valid JSON should not error");
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].templatable_mcp_server.name, "my-server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_json_parses_mcp_underscore_servers_key() {
|
||||
let json = r#"{
|
||||
"mcp_servers": {
|
||||
"s": { "url": "https://example.com/mcp" }
|
||||
}
|
||||
}"#;
|
||||
|
||||
let servers = ParsedTemplatableMCPServerResult::from_config_file_json(json)
|
||||
.expect("valid JSON should not error");
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].templatable_mcp_server.name, "s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_json_returns_error_for_invalid_json() {
|
||||
let result = ParsedTemplatableMCPServerResult::from_config_file_json("not json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_user_json_still_accepts_bare_server_map() {
|
||||
// The permissive from_user_json should continue to accept bare maps
|
||||
// (for UI paste scenarios).
|
||||
let json = r#"{
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let servers =
|
||||
ParsedTemplatableMCPServerResult::from_user_json(json).expect("should parse bare map");
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].templatable_mcp_server.name, "github");
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! A peer wrapper that transparently handles reconnection when the underlying transport is closed.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use uuid::Uuid;
|
||||
use warpui::ModelSpawner;
|
||||
|
||||
use super::TemplatableMCPServerManager;
|
||||
|
||||
/// A wrapper around an MCP server connection that transparently handles reconnection.
|
||||
///
|
||||
/// When making requests (e.g., `call_tool` or `read_resource`), this type checks if the
|
||||
/// underlying transport is closed and automatically triggers reconnection before retrying
|
||||
/// the request.
|
||||
#[derive(Clone)]
|
||||
pub struct ReconnectingPeer {
|
||||
installation_uuid: Uuid,
|
||||
spawner: ModelSpawner<TemplatableMCPServerManager>,
|
||||
}
|
||||
|
||||
/// Error type for reconnecting peer operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ReconnectingPeerError {
|
||||
#[error("Service error: {0}")]
|
||||
Service(#[from] rmcp::ServiceError),
|
||||
#[error("Reconnection failed: {0}")]
|
||||
ReconnectionFailed(String),
|
||||
#[error("Model dropped")]
|
||||
ModelDropped,
|
||||
}
|
||||
|
||||
impl From<ReconnectingPeerError> for rmcp::ServiceError {
|
||||
fn from(e: ReconnectingPeerError) -> Self {
|
||||
rmcp::ServiceError::McpError(rmcp::model::ErrorData {
|
||||
code: rmcp::model::ErrorCode::INTERNAL_ERROR,
|
||||
message: e.to_string().into(),
|
||||
data: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ReconnectingPeer {
|
||||
/// Creates a new `ReconnectingPeer` with the given installation UUID and spawner.
|
||||
pub fn new(
|
||||
installation_uuid: Uuid,
|
||||
spawner: ModelSpawner<TemplatableMCPServerManager>,
|
||||
) -> Self {
|
||||
Self {
|
||||
installation_uuid,
|
||||
spawner,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the current peer if connected, or triggers reconnection and waits for it.
|
||||
async fn get_connected_peer(
|
||||
&self,
|
||||
) -> Result<rmcp::Peer<rmcp::RoleClient>, ReconnectingPeerError> {
|
||||
let installation_uuid = self.installation_uuid;
|
||||
|
||||
// First, check if we have a connected peer.
|
||||
let peer_result = self
|
||||
.spawner
|
||||
.spawn(move |manager, _ctx| manager.get_peer_if_connected(installation_uuid))
|
||||
.await
|
||||
.map_err(|_| ReconnectingPeerError::ModelDropped)?;
|
||||
|
||||
if let Some(peer) = peer_result {
|
||||
return Ok(peer);
|
||||
}
|
||||
|
||||
// Peer is not connected, trigger reconnection.
|
||||
log::debug!("Triggering reconnection for MCP server {installation_uuid}");
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
self.spawner
|
||||
.spawn(move |manager, ctx| {
|
||||
manager.reconnect_server(installation_uuid, tx, ctx);
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ReconnectingPeerError::ModelDropped)?;
|
||||
|
||||
// Wait for reconnection to complete.
|
||||
let peer = rx
|
||||
.await
|
||||
.map_err(|_| ReconnectingPeerError::ReconnectionFailed("Channel closed".to_string()))?
|
||||
.map_err(|e| ReconnectingPeerError::ReconnectionFailed(e.to_string()))?;
|
||||
|
||||
log::debug!("Reconnection completed for MCP server {installation_uuid}");
|
||||
Ok(peer)
|
||||
}
|
||||
|
||||
/// Executes a request with automatic retry on `TransportClosed` errors.
|
||||
///
|
||||
/// If the initial request fails with `TransportClosed`, the reconnecting peer will
|
||||
/// detect the closed transport and attempt to reconnect before the retry.
|
||||
///
|
||||
/// Note: We intentionally retry only once to avoid infinite reconnection loops if the
|
||||
/// server is persistently failing. If the retry also fails, the error propagates to the
|
||||
/// caller.
|
||||
async fn with_reconnect_retry<T, R, F, Fut>(
|
||||
&self,
|
||||
params: T,
|
||||
f: F,
|
||||
) -> Result<R, rmcp::ServiceError>
|
||||
where
|
||||
T: Clone,
|
||||
F: Fn(rmcp::Peer<rmcp::RoleClient>, T) -> Fut,
|
||||
Fut: Future<Output = Result<R, rmcp::ServiceError>>,
|
||||
{
|
||||
let peer = self.get_connected_peer().await?;
|
||||
match f(peer, params.clone()).await {
|
||||
Err(rmcp::ServiceError::TransportClosed) => {
|
||||
let peer = self.get_connected_peer().await?;
|
||||
f(peer, params).await
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls a tool on the MCP server.
|
||||
pub async fn call_tool(
|
||||
&self,
|
||||
params: rmcp::model::CallToolRequestParam,
|
||||
) -> Result<rmcp::model::CallToolResult, rmcp::ServiceError> {
|
||||
self.with_reconnect_retry(params, |peer, p| async move { peer.call_tool(p).await })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reads a resource from the MCP server.
|
||||
pub async fn read_resource(
|
||||
&self,
|
||||
params: rmcp::model::ReadResourceRequestParam,
|
||||
) -> Result<rmcp::model::ReadResourceResult, rmcp::ServiceError> {
|
||||
self.with_reconnect_retry(params, |peer, p| async move { peer.read_resource(p).await })
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::DateTime;
|
||||
use handlebars::get_arguments;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{AppContext, SingletonEntity as _};
|
||||
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::{
|
||||
generic_string_model::{GenericStringModel, GenericStringObjectId, StringModel},
|
||||
json_model::{JsonModel, JsonSerializer},
|
||||
persistence::CloudModel,
|
||||
},
|
||||
GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey,
|
||||
JsonObjectType, Revision, ServerCloudObject, UniquePer,
|
||||
},
|
||||
drive::items::WarpDriveItem,
|
||||
server::{datetime_ext::DateTimeExt, ids::SyncId, sync_queue::QueueItem},
|
||||
};
|
||||
|
||||
const UNIQUENESS_KEY_PREFIX: &str = "templatable_mcp_server";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
|
||||
pub struct JsonTemplate {
|
||||
pub json: String,
|
||||
pub variables: Vec<TemplateVariable>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub struct TemplateVariable {
|
||||
pub key: String,
|
||||
/// When present, the variable should be filled via a dropdown of these values
|
||||
/// instead of a freetext input.
|
||||
#[serde(default)]
|
||||
pub allowed_values: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GalleryData {
|
||||
pub gallery_item_id: Uuid,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct TemplatableMCPServer {
|
||||
pub uuid: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub template: JsonTemplate,
|
||||
#[serde(default)]
|
||||
pub version: i64, // This will default to 0 if stored objects have no version
|
||||
pub gallery_data: Option<GalleryData>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FromStoredJsonError {
|
||||
NoServersFound,
|
||||
TooManyServersFound,
|
||||
ParseError(serde_json::Error),
|
||||
}
|
||||
|
||||
impl TemplatableMCPServer {
|
||||
/// Looks for MCP servers under known wrapper keys (`mcpServers`, `servers`,
|
||||
/// `mcp.servers`, `mcp_servers`). Returns `None` if no known key is found.
|
||||
fn find_servers_under_known_keys(
|
||||
config: &serde_json::Value,
|
||||
) -> Option<HashMap<String, serde_json::Value>> {
|
||||
const POINTERS: [&str; 4] = ["/mcp/servers", "/servers", "/mcpServers", "/mcp_servers"];
|
||||
for pointer in POINTERS {
|
||||
if let Some(value) = config.pointer(pointer) {
|
||||
if let Ok(servers) =
|
||||
serde_json::from_value::<HashMap<String, serde_json::Value>>(value.clone())
|
||||
{
|
||||
return Some(servers);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Permissively parses MCP servers from JSON.
|
||||
///
|
||||
/// Accepts servers under known wrapper keys (VSCode, Claude Desktop, etc.)
|
||||
/// and also falls back to treating the entire object as a bare server map.
|
||||
/// This is appropriate for user-pasted input.
|
||||
pub fn find_template_map(
|
||||
config: serde_json::Value,
|
||||
) -> serde_json::Result<HashMap<String, serde_json::Value>> {
|
||||
if let Some(servers) = Self::find_servers_under_known_keys(&config) {
|
||||
return Ok(servers);
|
||||
}
|
||||
// Fallback: treat the entire object as a bare map of servers.
|
||||
serde_json::from_value::<HashMap<String, serde_json::Value>>(config)
|
||||
}
|
||||
|
||||
/// Like [`find_template_map`], but without the bare-object fallback.
|
||||
///
|
||||
/// Returns servers only when found under a known wrapper key. This prevents
|
||||
/// misinterpreting unrelated JSON files (e.g. Claude Code's `~/.claude.json`
|
||||
/// settings) as MCP config.
|
||||
pub fn find_template_map_strict(
|
||||
config: &serde_json::Value,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
Self::find_servers_under_known_keys(config).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn to_user_json(&self) -> String {
|
||||
let value: serde_json::Value = serde_json::from_str(&self.template.json)
|
||||
// All templates should be valid JSON - this should never fail
|
||||
// Ones that are not should not have been saved in the first place
|
||||
.unwrap_or_else(|err| {
|
||||
log::error!("Could not parse MCP server template to json: {err:?}");
|
||||
Default::default()
|
||||
});
|
||||
|
||||
serde_json::to_string_pretty(&value)
|
||||
// serde_json::to_string_pretty should never fail on this value since we just parsed it as valid json
|
||||
.unwrap_or_else(|err| {
|
||||
log::error!("Could not serialize MCP server to user json: {err:?}");
|
||||
Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
// Uses from_user_json to parse the json and then returns the first TemplatableMCPServer
|
||||
// This is meant to be used for stored json from the database, which should only contain
|
||||
// a single server and already checked for json validity
|
||||
pub fn from_stored_json(
|
||||
json: &str,
|
||||
uuid: uuid::Uuid,
|
||||
) -> Result<TemplatableMCPServer, FromStoredJsonError> {
|
||||
let templates = Self::from_user_json(json);
|
||||
match templates {
|
||||
Ok(templates) => {
|
||||
if templates.is_empty() {
|
||||
// This should never happen for stored json from the database
|
||||
log::error!("No templatable MCP servers found in stored json: {uuid}");
|
||||
Err(FromStoredJsonError::NoServersFound)
|
||||
} else if templates.len() > 1 {
|
||||
Err(FromStoredJsonError::TooManyServersFound)
|
||||
} else {
|
||||
// templates should always contain exactly one server for stored json from the database
|
||||
let mut templatable_mcp_server = templates[0].clone();
|
||||
templatable_mcp_server.uuid = uuid;
|
||||
Ok(templatable_mcp_server)
|
||||
}
|
||||
}
|
||||
Err(err) => Err(FromStoredJsonError::ParseError(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_user_json(json: &str) -> serde_json::Result<Vec<TemplatableMCPServer>> {
|
||||
// Some docs don't show curly braces around the json object, so add them if necessary.
|
||||
let json = json.trim();
|
||||
let json = if json.starts_with("{") {
|
||||
json.to_owned()
|
||||
} else {
|
||||
format!("{{{json}}}")
|
||||
};
|
||||
|
||||
let config: serde_json::Value = serde_json::from_str(&json)?;
|
||||
|
||||
let template_jsons = Self::find_template_map(config)?;
|
||||
Ok(template_jsons
|
||||
.iter()
|
||||
.map(|(name, json)| {
|
||||
// Each template_json is the nested config for a single MCP server
|
||||
// We need to re-wrap it in a top level object so that we can
|
||||
// reuse from_user_json to read it later
|
||||
let normalized_map =
|
||||
serde_json::Map::from_iter(vec![(name.to_owned(), json.clone())]);
|
||||
let normalized_json = serde_json::Value::Object(normalized_map).to_string();
|
||||
|
||||
let description: Option<String> = json
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str().map(|s| s.to_owned()));
|
||||
let arguments = get_arguments(&normalized_json);
|
||||
let variables = arguments
|
||||
.iter()
|
||||
.map(|argument| TemplateVariable {
|
||||
key: argument.to_owned(),
|
||||
allowed_values: None,
|
||||
})
|
||||
.collect::<Vec<TemplateVariable>>();
|
||||
|
||||
TemplatableMCPServer {
|
||||
uuid: uuid::Uuid::new_v4(),
|
||||
name: name.to_owned(),
|
||||
description,
|
||||
template: JsonTemplate {
|
||||
json: normalized_json,
|
||||
variables,
|
||||
},
|
||||
version: DateTime::now().timestamp(),
|
||||
gallery_data: None,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudTemplatableMCPServer =
|
||||
GenericCloudObject<GenericStringObjectId, CloudTemplatableMCPServerModel>;
|
||||
pub type CloudTemplatableMCPServerModel = GenericStringModel<TemplatableMCPServer, JsonSerializer>;
|
||||
|
||||
impl CloudTemplatableMCPServer {
|
||||
pub fn get_all(app: &AppContext) -> Vec<CloudTemplatableMCPServer> {
|
||||
CloudModel::as_ref(app)
|
||||
.get_all_objects_of_type::<GenericStringObjectId, CloudTemplatableMCPServerModel>()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_by_id<'a>(
|
||||
sync_id: &'a SyncId,
|
||||
app: &'a AppContext,
|
||||
) -> Option<&'a CloudTemplatableMCPServer> {
|
||||
CloudModel::as_ref(app)
|
||||
.get_object_of_type::<GenericStringObjectId, CloudTemplatableMCPServerModel>(sync_id)
|
||||
}
|
||||
|
||||
pub fn get_by_uuid<'a>(
|
||||
uuid: &'a uuid::Uuid,
|
||||
app: &'a AppContext,
|
||||
) -> Option<&'a CloudTemplatableMCPServer> {
|
||||
CloudModel::as_ref(app)
|
||||
.get_all_objects_of_type::<GenericStringObjectId, CloudTemplatableMCPServerModel>()
|
||||
.find(|server| server.model().string_model.uuid == *uuid)
|
||||
}
|
||||
}
|
||||
|
||||
impl StringModel for TemplatableMCPServer {
|
||||
type CloudObjectType = CloudTemplatableMCPServer;
|
||||
|
||||
fn model_type_name(&self) -> &'static str {
|
||||
"MCP server"
|
||||
}
|
||||
|
||||
fn should_enforce_revisions() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn model_format() -> GenericStringObjectFormat {
|
||||
GenericStringObjectFormat::Json(JsonObjectType::TemplatableMCPServer)
|
||||
}
|
||||
|
||||
fn should_show_activity_toasts() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn warn_if_unsaved_at_quit() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn update_object_queue_item(
|
||||
&self,
|
||||
revision_ts: Option<Revision>,
|
||||
object: &Self::CloudObjectType,
|
||||
) -> QueueItem {
|
||||
QueueItem::UpdateTemplatableMCPServer {
|
||||
model: object.model().clone().into(),
|
||||
id: object.id,
|
||||
revision: revision_ts.or_else(|| object.metadata.revision.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
|
||||
if let ServerCloudObject::TemplatableMCPServer(server_templatable_mcp_server) =
|
||||
server_cloud_object
|
||||
{
|
||||
return Some(server_templatable_mcp_server.model.clone().string_model);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn uniqueness_key(&self) -> Option<GenericStringObjectUniqueKey> {
|
||||
Some(GenericStringObjectUniqueKey {
|
||||
key: format!("{UNIQUENESS_KEY_PREFIX}_{}", self.uuid),
|
||||
unique_per: UniquePer::User,
|
||||
})
|
||||
}
|
||||
|
||||
fn renders_in_warp_drive(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn to_warp_drive_item(
|
||||
&self,
|
||||
_id: SyncId,
|
||||
_appearance: &Appearance,
|
||||
_templatable_mcp_server: &CloudTemplatableMCPServer,
|
||||
) -> Option<Box<dyn WarpDriveItem>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for TemplatableMCPServer {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::TemplatableMCPServer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use handlebars::{get_arguments, render_template};
|
||||
use lazy_static::lazy_static;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use uuid::Uuid;
|
||||
use warp_managed_secrets::ManagedSecretValue;
|
||||
|
||||
use crate::ai::mcp::{TemplatableMCPServer, TemplateVariable};
|
||||
use siphasher::sip::SipHasher;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
lazy_static! {
|
||||
static ref HASHER: SipHasher = SipHasher::new_with_keys(0, 0);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum VariableType {
|
||||
Text,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct VariableValue {
|
||||
pub variable_type: VariableType,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TemplatableMCPServerInstallation {
|
||||
uuid: Uuid,
|
||||
templatable_mcp_server: TemplatableMCPServer,
|
||||
variable_values: HashMap<String, VariableValue>,
|
||||
}
|
||||
|
||||
impl TemplatableMCPServerInstallation {
|
||||
pub fn new(
|
||||
uuid: Uuid,
|
||||
templatable_mcp_server: TemplatableMCPServer,
|
||||
variable_values: HashMap<String, VariableValue>,
|
||||
) -> TemplatableMCPServerInstallation {
|
||||
TemplatableMCPServerInstallation {
|
||||
uuid,
|
||||
templatable_mcp_server,
|
||||
variable_values,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a consistent hash for the installation based on the MCP server's name, JsonTemplate, and variable values.
|
||||
/// Returns None if the variable values cannot be serialized.
|
||||
pub fn hash(&self) -> Option<u64> {
|
||||
let mut hasher = *HASHER;
|
||||
|
||||
let name = self.templatable_mcp_server.name.as_str();
|
||||
let template_json = self.templatable_mcp_server.template.json.as_str();
|
||||
|
||||
// Converts the variable values to a sorted BTreeMap for consistent hashing
|
||||
let variable_values: BTreeMap<String, String> = self
|
||||
.variable_values
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.value.clone()))
|
||||
.collect();
|
||||
let variable_values_json = match serde_json::to_string(&variable_values) {
|
||||
Ok(json) => json,
|
||||
Err(err) => {
|
||||
log::error!("Failed to serialize variable values for hashing: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Hashes the name, template JSON, and variable values
|
||||
(name, template_json, variable_values_json).hash(&mut hasher);
|
||||
|
||||
Some(hasher.finish())
|
||||
}
|
||||
|
||||
pub fn uuid(&self) -> Uuid {
|
||||
self.uuid
|
||||
}
|
||||
|
||||
pub fn templatable_mcp_server(&self) -> &TemplatableMCPServer {
|
||||
&self.templatable_mcp_server
|
||||
}
|
||||
|
||||
pub fn template_uuid(&self) -> Uuid {
|
||||
self.templatable_mcp_server.uuid
|
||||
}
|
||||
|
||||
pub fn template_json(&self) -> &str {
|
||||
&self.templatable_mcp_server.template.json
|
||||
}
|
||||
|
||||
pub fn template_variables(&self) -> &Vec<TemplateVariable> {
|
||||
&self.templatable_mcp_server.template.variables
|
||||
}
|
||||
|
||||
pub fn variable_values(&self) -> &HashMap<String, VariableValue> {
|
||||
&self.variable_values
|
||||
}
|
||||
|
||||
/// Apply Warp-managed secrets to the installation's variable values.
|
||||
///
|
||||
/// Precedence for each template variable:
|
||||
/// 1. Explicit reference: if the current value contains `{{secret_name}}`
|
||||
/// placeholders, they are rendered against the secrets map. Any other
|
||||
/// secrets that happen to share the variable's key name are ignored.
|
||||
/// 2. Implicit key-name match: if the current value has no `{{...}}`
|
||||
/// placeholders but a secret exists whose name equals the variable key,
|
||||
/// that secret's value is inserted.
|
||||
///
|
||||
/// Variables with no matching explicit refs and no matching secret are left
|
||||
/// unchanged.
|
||||
pub fn apply_secrets(&mut self, secrets: &HashMap<String, ManagedSecretValue>) {
|
||||
let secret_strings: HashMap<String, String> = secrets
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let ManagedSecretValue::RawValue { value } = v else {
|
||||
return None;
|
||||
};
|
||||
Some((k.clone(), value.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Access templatable_mcp_server directly instead of using template_variables() to allow mutating
|
||||
// variable_values while borrowing the template.
|
||||
for variable in self.templatable_mcp_server.template.variables.iter() {
|
||||
let has_explicit_refs = self
|
||||
.variable_values
|
||||
.get(&variable.key)
|
||||
.is_some_and(|v| !get_arguments(&v.value).is_empty());
|
||||
|
||||
if has_explicit_refs {
|
||||
let rendered =
|
||||
render_template(&self.variable_values[&variable.key].value, &secret_strings);
|
||||
self.variable_values.insert(
|
||||
variable.key.clone(),
|
||||
VariableValue {
|
||||
variable_type: VariableType::Text,
|
||||
value: rendered,
|
||||
},
|
||||
);
|
||||
} else if let Some(secret) = secrets.get(&variable.key) {
|
||||
let ManagedSecretValue::RawValue { value } = secret else {
|
||||
// We don't support injecting other secret types.
|
||||
continue;
|
||||
};
|
||||
self.variable_values.insert(
|
||||
variable.key.clone(),
|
||||
VariableValue {
|
||||
variable_type: VariableType::Text,
|
||||
value: value.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gallery_uuid(&self) -> Option<Uuid> {
|
||||
self.templatable_mcp_server
|
||||
.gallery_data
|
||||
.as_ref()
|
||||
.map(|g| g.gallery_item_id)
|
||||
}
|
||||
|
||||
pub fn gallery_version(&self) -> Option<i32> {
|
||||
self.templatable_mcp_server
|
||||
.gallery_data
|
||||
.as_ref()
|
||||
.map(|g| g.version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod native;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use native::McpIntegration;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod oauth;
|
||||
#[cfg(target_family = "wasm")]
|
||||
mod wasm;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use diesel::SqliteConnection;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::mcp::templatable::CloudTemplatableMCPServer;
|
||||
use crate::ai::mcp::FileBasedMCPManager;
|
||||
use crate::ai::mcp::{templatable_installation::TemplatableMCPServerInstallation, MCPServerState};
|
||||
use futures_util::stream::AbortHandle;
|
||||
use uuid::Uuid;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::ModelSpawner;
|
||||
use warpui::{Entity, SingletonEntity};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
type ReconnectResultSender =
|
||||
tokio::sync::oneshot::Sender<Result<rmcp::Peer<rmcp::RoleClient>, String>>;
|
||||
|
||||
/// Singleton model to manage state of MCP server lifecycles and panes across multiple windows
|
||||
/// (where only one MCP server pane can exist per window).
|
||||
///
|
||||
/// Specifically:
|
||||
/// - Maintains MCP server view handles to preserve state when panes are hidden
|
||||
/// - Tracks currently open MCP server panes and their location
|
||||
///
|
||||
/// The core implementations are in the `native` and `wasm` modules.
|
||||
#[derive(Default)]
|
||||
pub struct TemplatableMCPServerManager {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
cloud_templatable_mcp_servers: HashMap<Uuid, CloudTemplatableMCPServer>,
|
||||
locally_installed_servers: HashMap<Uuid, TemplatableMCPServerInstallation>,
|
||||
server_states: HashMap<Uuid, MCPServerState>,
|
||||
active_servers: HashMap<Uuid, TemplatableMCPServerInfo>,
|
||||
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
spawned_servers: HashMap<Uuid, SpawnedServerInfo>,
|
||||
/// Cached credentials for each server.
|
||||
///
|
||||
/// We persist these to secure storage, and if they are present when the server is started,
|
||||
/// we use them instead of going through the OAuth flow again.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
server_credentials: oauth::PersistedCredentialsMap,
|
||||
/// Cached credentials for file-based servers, keyed by installation hash.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
file_based_server_credentials: oauth::FileBasedPersistedCredentialsMap,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
database_connection: Option<Arc<Mutex<SqliteConnection>>>,
|
||||
/// Error messages for failed servers, keyed by installation UUID.
|
||||
server_error_messages: HashMap<Uuid, String>,
|
||||
/// Spawner for running tasks in the context of this manager.
|
||||
///
|
||||
/// Used by `ReconnectingPeer` to trigger reconnection from async contexts.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
spawner: Option<ModelSpawner<Self>>,
|
||||
/// Pending reconnection waiters, keyed by installation UUID.
|
||||
///
|
||||
/// When a reconnection is in progress, subsequent reconnect requests for the same server
|
||||
/// will add their result channels here instead of starting a new reconnection. When the
|
||||
/// reconnection completes, all waiters are notified with the result.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pending_reconnections: HashMap<Uuid, Vec<ReconnectResultSender>>,
|
||||
/// Maps the OAuth CSRF `state` token to the installation UUID of the server whose
|
||||
/// authorization flow is in progress.
|
||||
///
|
||||
/// Populated just before opening the authorization URL; removed once the callback
|
||||
/// is received or the spawn task terminates.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pending_oauth_csrf: HashMap<String, Uuid>,
|
||||
/// UUIDs of MCP servers started via the Oz CLI. We track these so they can be distinguished from
|
||||
/// file-based ephemeral MCP servers, which are directory-scoped.
|
||||
cli_spawned_server_uuids: HashSet<Uuid>,
|
||||
}
|
||||
|
||||
/// Information about a spawned server task.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
struct SpawnedServerInfo {
|
||||
abort_handle: AbortHandle,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
oauth_result_tx: async_channel::Sender<oauth::CallbackResult>,
|
||||
}
|
||||
|
||||
/// Information about a single connected MCP server.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub struct TemplatableMCPServerInfo {
|
||||
name: String,
|
||||
service: rmcp::service::RunningService<
|
||||
rmcp::RoleClient,
|
||||
Box<dyn rmcp::service::DynService<rmcp::RoleClient>>,
|
||||
>,
|
||||
resources: Vec<rmcp::model::Resource>,
|
||||
tools: Vec<rmcp::model::Tool>,
|
||||
installation_id: Uuid,
|
||||
description: Option<String>,
|
||||
/// Whether the underlying transport uses authentication.
|
||||
///
|
||||
/// TODO(vorporeal): Use this to display a toast when server authentication and connection is complete, and
|
||||
/// to provide a "log out" button.
|
||||
#[allow(dead_code)]
|
||||
is_authenticated_transport: bool,
|
||||
}
|
||||
|
||||
impl TemplatableMCPServerInfo {
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn resources(&self) -> &Vec<rmcp::model::Resource> {
|
||||
&self.resources
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> &Vec<rmcp::model::Tool> {
|
||||
&self.tools
|
||||
}
|
||||
|
||||
pub fn installation_id(&self) -> Uuid {
|
||||
self.installation_id
|
||||
}
|
||||
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.description.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// The current status of the Figma MCP server.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FigmaMcpStatus {
|
||||
/// The Figma MCP server is not installed.
|
||||
NotInstalled,
|
||||
/// The Figma MCP server is installed but not currently running.
|
||||
Installed,
|
||||
/// The Figma MCP server is in the process of enabling (e.g. OAuth flow in progress).
|
||||
Enabling,
|
||||
/// The Figma MCP server is running.
|
||||
Running,
|
||||
}
|
||||
|
||||
impl TemplatableMCPServerManager {
|
||||
pub fn get_installed_templatable_servers(
|
||||
&self,
|
||||
) -> &HashMap<Uuid, TemplatableMCPServerInstallation> {
|
||||
&self.locally_installed_servers
|
||||
}
|
||||
|
||||
pub fn get_installed_server(
|
||||
&self,
|
||||
installation_uuid: &Uuid,
|
||||
) -> Option<&TemplatableMCPServerInstallation> {
|
||||
self.locally_installed_servers.get(installation_uuid)
|
||||
}
|
||||
|
||||
/// Returns the UUID of the locally-installed Figma MCP server installation, if any.
|
||||
pub fn get_figma_installation_uuid(&self) -> Option<Uuid> {
|
||||
self.locally_installed_servers
|
||||
.iter()
|
||||
.find(|(_, installation)| {
|
||||
installation
|
||||
.template_json()
|
||||
.contains("https://mcp.figma.com/mcp")
|
||||
})
|
||||
.map(|(uuid, _)| *uuid)
|
||||
}
|
||||
|
||||
/// Returns the current status of the Figma MCP server.
|
||||
pub fn get_figma_mcp_status(&self) -> FigmaMcpStatus {
|
||||
let Some(uuid) = self.get_figma_installation_uuid() else {
|
||||
return FigmaMcpStatus::NotInstalled;
|
||||
};
|
||||
if self.active_servers.contains_key(&uuid) {
|
||||
FigmaMcpStatus::Running
|
||||
} else if self.spawned_servers.contains_key(&uuid) {
|
||||
FigmaMcpStatus::Enabling
|
||||
} else {
|
||||
FigmaMcpStatus::Installed
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_template_uuid(&self, installation_uuid: Uuid) -> Option<Uuid> {
|
||||
self.locally_installed_servers
|
||||
.get(&installation_uuid)
|
||||
.map(|server_installation| server_installation.template_uuid())
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn is_server_active(&self, installation_uuid: Uuid) -> bool {
|
||||
self.active_servers.contains_key(&installation_uuid)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn is_server_active_or_pending(&self, uuid: Uuid) -> bool {
|
||||
self.is_server_active(uuid) || self.spawned_servers.contains_key(&uuid)
|
||||
}
|
||||
|
||||
pub fn get_server_state(&self, installation_uuid: Uuid) -> Option<MCPServerState> {
|
||||
self.server_states.get(&installation_uuid).copied()
|
||||
}
|
||||
|
||||
pub fn get_server_error_message(&self, installation_uuid: Uuid) -> Option<&str> {
|
||||
self.server_error_messages
|
||||
.get(&installation_uuid)
|
||||
.map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn resources(&self) -> impl Iterator<Item = &rmcp::model::Resource> {
|
||||
self.active_servers
|
||||
.values()
|
||||
.flat_map(|server| server.resources.iter())
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> impl Iterator<Item = &rmcp::model::Tool> {
|
||||
self.active_servers
|
||||
.values()
|
||||
.flat_map(|server| server.tools.iter())
|
||||
}
|
||||
|
||||
/// Returns a reconnecting peer for a server that has the given resource.
|
||||
///
|
||||
/// The returned peer will automatically reconnect if the underlying transport is closed.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn server_with_resource(
|
||||
&self,
|
||||
resource: &rmcp::model::Resource,
|
||||
) -> Option<super::reconnecting_peer::ReconnectingPeer> {
|
||||
let spawner = self.spawner.as_ref()?;
|
||||
self.active_servers
|
||||
.iter()
|
||||
.find(|(_, server)| {
|
||||
server
|
||||
.resources
|
||||
.iter()
|
||||
.any(|other_resource| resource.uri == other_resource.uri)
|
||||
})
|
||||
.map(|(installation_uuid, _)| {
|
||||
super::reconnecting_peer::ReconnectingPeer::new(*installation_uuid, spawner.clone())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tools_for_server(&self, uuid: Uuid) -> Vec<rmcp::model::Tool> {
|
||||
self.active_servers
|
||||
.get(&uuid)
|
||||
.map(|server| server.tools.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns the JSON Schema `input_schema` for a named tool across active MCP servers.
|
||||
///
|
||||
/// If `installation_id` is `Some`, only that server is considered; otherwise, the
|
||||
/// first active server providing a matching tool name wins (matching the existing
|
||||
/// `server_with_tool_name` lookup behavior).
|
||||
///
|
||||
/// Used by the MCP tool executor to coerce integer-typed args before dispatch, since
|
||||
/// `structpb.NumberValue` on the wire cannot preserve the integer/float distinction.
|
||||
/// See <https://json-schema.org/understanding-json-schema/reference/type>.
|
||||
pub fn tool_input_schema(
|
||||
&self,
|
||||
installation_id: Option<Uuid>,
|
||||
tool_name: &str,
|
||||
) -> Option<std::sync::Arc<rmcp::model::JsonObject>> {
|
||||
let candidates: Box<dyn Iterator<Item = &TemplatableMCPServerInfo>> =
|
||||
if let Some(uuid) = installation_id {
|
||||
Box::new(self.active_servers.get(&uuid).into_iter())
|
||||
} else {
|
||||
Box::new(self.active_servers.values())
|
||||
};
|
||||
|
||||
candidates
|
||||
.flat_map(|server| server.tools.iter())
|
||||
.find(|t| t.name == tool_name)
|
||||
.map(|t| t.input_schema.clone())
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn server_from_tool(&self, tool: String) -> Option<&Uuid> {
|
||||
self.active_servers
|
||||
.iter()
|
||||
.find(|(_, server)| server.tools.iter().any(|t| t.name == tool))
|
||||
.map(|(uuid, _)| uuid)
|
||||
}
|
||||
|
||||
/// Returns the installation UUID of the server that provides a resource matching the given
|
||||
/// name or URI.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn server_from_resource(&self, name: &str, uri: Option<&str>) -> Option<&Uuid> {
|
||||
self.active_servers
|
||||
.iter()
|
||||
.find(|(_, server)| {
|
||||
server.resources.iter().any(|r| {
|
||||
if let Some(uri) = uri {
|
||||
r.uri == uri
|
||||
} else {
|
||||
r.name == name
|
||||
}
|
||||
})
|
||||
})
|
||||
.map(|(uuid, _)| uuid)
|
||||
}
|
||||
|
||||
/// Returns installed templatable servers that are currently active.
|
||||
pub fn get_active_templatable_servers(&self) -> HashMap<Uuid, &TemplatableMCPServerInfo> {
|
||||
self.locally_installed_servers
|
||||
.keys()
|
||||
.filter_map(|uuid| self.active_servers.get(uuid).map(|info| (*uuid, info)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns file-based MCP servers that are currently active and in scope for the given working directory.
|
||||
pub fn get_active_file_based_servers(
|
||||
&self,
|
||||
cwd: &std::path::Path,
|
||||
app: &warpui::AppContext,
|
||||
) -> HashMap<Uuid, &TemplatableMCPServerInfo> {
|
||||
FileBasedMCPManager::as_ref(app)
|
||||
.get_servers_for_working_directory(cwd, app)
|
||||
.iter()
|
||||
.filter_map(|installation| {
|
||||
let uuid = installation.uuid();
|
||||
self.active_servers.get(&uuid).map(|info| (uuid, info))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns CLI-spawned ephemeral servers (started via `oz agent run --mcp`) that are currently active.
|
||||
pub fn get_active_cli_spawned_servers(&self) -> HashMap<Uuid, &TemplatableMCPServerInfo> {
|
||||
self.cli_spawned_server_uuids
|
||||
.iter()
|
||||
.filter_map(|uuid| self.active_servers.get(uuid).map(|info| (*uuid, info)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub enum TemplatableMCPServerManagerEvent {
|
||||
StateChanged {
|
||||
uuid: Uuid,
|
||||
state: MCPServerState,
|
||||
},
|
||||
// TODO(aeybel) Right now most of the app doesn't use these events to communicate
|
||||
// We should change them so this manager is source of truth and all communication goes through here
|
||||
#[allow(dead_code)]
|
||||
ServerInstallationAdded(Uuid),
|
||||
#[allow(dead_code)]
|
||||
ServerInstallationDeleted(Uuid),
|
||||
TemplatableMCPServersUpdated,
|
||||
LegacyServerConverted,
|
||||
}
|
||||
|
||||
impl Entity for TemplatableMCPServerManager {
|
||||
type Event = TemplatableMCPServerManagerEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for TemplatableMCPServerManager {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,571 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{anyhow, bail};
|
||||
use oauth2::{RefreshToken, TokenResponse as _};
|
||||
use rmcp::transport::{
|
||||
auth::{
|
||||
AuthClient, AuthorizationManager, CredentialStore, InMemoryCredentialStore,
|
||||
OAuthClientConfig, OAuthState, OAuthTokenResponse, StoredCredentials,
|
||||
},
|
||||
AuthError, AuthorizationSession,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
use warp_core::channel::ChannelState;
|
||||
use warpui::ModelSpawner;
|
||||
use warpui_extras::secure_storage::AppContextExt as _;
|
||||
|
||||
use super::{MCPServerState, TemplatableMCPServerManager};
|
||||
use {crate::ai::mcp::FileBasedMCPManager, warpui::SingletonEntity};
|
||||
|
||||
pub(crate) const TEMPLATABLE_MCP_CREDENTIALS_KEY: &str = "TemplatableMcpCredentials";
|
||||
pub(crate) const FILE_BASED_MCP_CREDENTIALS_KEY: &str = "FileBasedMcpCredentials";
|
||||
|
||||
/// The issuer URL for GitHub's OAuth provider.
|
||||
const GITHUB_ISSUER: &str = "https://github.com/login/oauth";
|
||||
|
||||
static GITHUB_OAUTH_SCOPES: [&str; 7] = [
|
||||
"repo",
|
||||
"read:org",
|
||||
"gist",
|
||||
"notifications",
|
||||
"user",
|
||||
"project",
|
||||
"workflow",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedCredentials {
|
||||
client_id: String,
|
||||
client_secret: Option<String>,
|
||||
token_response: OAuthTokenResponse,
|
||||
}
|
||||
|
||||
/// Maps cloud MCP installation UUID to its OAuth credentials in secure storage.
|
||||
pub type PersistedCredentialsMap = HashMap<Uuid, PersistedCredentials>;
|
||||
|
||||
// Maps a consistent hash of the installation to its persisted credentials
|
||||
pub type FileBasedPersistedCredentialsMap = HashMap<u64, PersistedCredentials>;
|
||||
|
||||
/// A credential store that wraps [`InMemoryCredentialStore`] and persists token
|
||||
/// updates to Warp's secure storage via a channel.
|
||||
///
|
||||
/// When rmcp auto-refreshes an expired access token at runtime, the rotated
|
||||
/// tokens are only saved to the in-memory store by default. This wrapper
|
||||
/// ensures they also get written back to secure storage so they survive app
|
||||
/// restarts.
|
||||
struct PersistingCredentialStore {
|
||||
inner: InMemoryCredentialStore,
|
||||
client_secret: Option<String>,
|
||||
persist_tx: async_channel::Sender<PersistedCredentials>,
|
||||
}
|
||||
|
||||
impl PersistingCredentialStore {
|
||||
/// Per RFC 6749 §6, the authorization server MAY issue a new refresh token on
|
||||
/// refresh, but is not required to. Many OAuth providers (e.g. Figma) only
|
||||
/// issue a refresh token on the initial authorization grant and omit it from
|
||||
/// subsequent refresh responses. If we blindly persist the new token response,
|
||||
/// the refresh token is lost and the next session (or next in-process refresh)
|
||||
/// requires a full re-auth.
|
||||
///
|
||||
/// When the new response omits a refresh token, carry forward the one already
|
||||
/// in the store. See: <https://datatracker.ietf.org/doc/html/rfc6749#section-6>
|
||||
async fn apply_refresh_token_carry_forward(&self, credentials: &mut StoredCredentials) {
|
||||
if credentials
|
||||
.token_response
|
||||
.as_ref()
|
||||
.is_none_or(|tr| tr.refresh_token().is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(prev_rt) = self
|
||||
.inner
|
||||
.load()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|opt| opt)
|
||||
.and_then(|prev| prev.token_response)
|
||||
.and_then(|prev_tr| prev_tr.refresh_token().cloned())
|
||||
{
|
||||
if let Some(tr) = credentials.token_response.as_mut() {
|
||||
// Carry forward the existing/previous refresh token, constructing new if needed
|
||||
tr.set_refresh_token(Some(RefreshToken::new(prev_rt.secret().to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CredentialStore for PersistingCredentialStore {
|
||||
async fn load(&self) -> Result<Option<StoredCredentials>, AuthError> {
|
||||
self.inner.load().await
|
||||
}
|
||||
|
||||
async fn save(&self, mut credentials: StoredCredentials) -> Result<(), AuthError> {
|
||||
self.apply_refresh_token_carry_forward(&mut credentials)
|
||||
.await;
|
||||
|
||||
self.inner.save(credentials.clone()).await?;
|
||||
|
||||
if let Some(token_response) = credentials.token_response {
|
||||
let _ = self.persist_tx.try_send(PersistedCredentials {
|
||||
client_id: credentials.client_id,
|
||||
client_secret: self.client_secret.clone(),
|
||||
token_response,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear(&self) -> Result<(), AuthError> {
|
||||
self.inner.clear().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs a [`PersistingCredentialStore`] on the given auth manager so that
|
||||
/// runtime token auto-refreshes are written back to Warp's secure storage.
|
||||
///
|
||||
/// A background tokio task is spawned to receive credential updates and persist
|
||||
/// them via the [`ModelSpawner`]. The task terminates when the auth manager (and
|
||||
/// thus the credential store's sender) is dropped.
|
||||
///
|
||||
/// Note: this store is not responsible for the initial population of credentials.
|
||||
/// Instead, the caller seeds the inner store with any existing credentials prior
|
||||
/// to installation (see [`install_persisting_credential_store`]). This store's
|
||||
/// sole role is to write token updates back to secure storage as they occur.
|
||||
async fn install_persisting_credential_store(
|
||||
auth_manager: &mut AuthorizationManager,
|
||||
client_secret: Option<String>,
|
||||
spawner: ModelSpawner<TemplatableMCPServerManager>,
|
||||
installation_uuid: Uuid,
|
||||
) {
|
||||
let (persist_tx, persist_rx) = async_channel::unbounded();
|
||||
let store = PersistingCredentialStore {
|
||||
inner: InMemoryCredentialStore::new(),
|
||||
client_secret,
|
||||
persist_tx,
|
||||
};
|
||||
|
||||
// Seed the new store with the current credentials so that subsequent
|
||||
// get_access_token() calls can find them.
|
||||
if let Ok((client_id, Some(token_response))) = auth_manager.get_credentials().await {
|
||||
let _ = store
|
||||
.inner
|
||||
.save(StoredCredentials {
|
||||
client_id,
|
||||
token_response: Some(token_response),
|
||||
granted_scopes: Vec::new(),
|
||||
token_received_at: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
auth_manager.set_credential_store(store);
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Ok(credentials) = persist_rx.recv().await {
|
||||
if let Err(e) = spawner
|
||||
.spawn(move |manager, ctx| {
|
||||
manager.save_credentials_to_secure_storage(ctx, installation_uuid, credentials);
|
||||
})
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to persist auto-refreshed MCP credentials: {e:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Context for OAuth authentication flows.
|
||||
pub struct AuthContext {
|
||||
pub oauth_result_rx: async_channel::Receiver<CallbackResult>,
|
||||
pub spawner: ModelSpawner<TemplatableMCPServerManager>,
|
||||
pub uuid: Uuid,
|
||||
pub persisted_credentials: Option<PersistedCredentials>,
|
||||
/// Whether the client is running in headless/CLI mode.
|
||||
pub is_headless: bool,
|
||||
/// Whether this server was auto-discovered from a repo MCP configuration file.
|
||||
pub is_file_based: bool,
|
||||
}
|
||||
|
||||
/// Result of OAuth callback.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CallbackResult {
|
||||
Success { code: String, csrf_token: String },
|
||||
Error { error: Option<String> },
|
||||
}
|
||||
|
||||
/// Makes an authenticated client for the given authorization server.
|
||||
///
|
||||
/// This takes in the URL of the resource to authenticate for, and uses that
|
||||
/// to determine the authorization server.
|
||||
///
|
||||
/// Upon success, returns the client and a boolean indicating whether the user was required to
|
||||
/// re-authenticate (e.g. re-log in).
|
||||
pub async fn make_authenticated_client(
|
||||
resource_url: &str,
|
||||
auth_context: AuthContext,
|
||||
) -> Result<(AuthClient<reqwest::Client>, bool), AuthError> {
|
||||
let AuthContext {
|
||||
oauth_result_rx,
|
||||
spawner,
|
||||
uuid,
|
||||
persisted_credentials,
|
||||
is_headless,
|
||||
is_file_based,
|
||||
} = auth_context;
|
||||
|
||||
// Build the redirect URI using the channel's URL scheme.
|
||||
// Routing data (the server UUID) is passed via the OAuth `state` parameter instead
|
||||
// of the redirect URI so that the URI exactly matches what is registered during
|
||||
// Dynamic Client Registration, satisfying RFC 6749 §3.1.2.2 exact-match validation.
|
||||
let redirect_uri = format!("{}://mcp/oauth2callback", ChannelState::url_scheme());
|
||||
|
||||
// Create the OAuth state machine.
|
||||
let mut oauth_state = OAuthState::new(resource_url, None).await?;
|
||||
|
||||
// If we have cached credentials, use them.
|
||||
if let Some(credentials) = persisted_credentials {
|
||||
let provider = ChannelState::mcp_oauth_provider_by_client_id(&credentials.client_id);
|
||||
let client_secret = credentials
|
||||
.client_secret
|
||||
.or_else(|| provider.as_ref().map(|p| p.client_secret.to_string()));
|
||||
oauth_state
|
||||
.set_credentials(&credentials.client_id, credentials.token_response)
|
||||
.await?;
|
||||
if let OAuthState::Authorized(mut auth_manager) = oauth_state {
|
||||
// If this is a client for which we have a known client secret,
|
||||
// update our client config accordingly.
|
||||
if let Some(client_secret) = &client_secret {
|
||||
auth_manager.configure_client(OAuthClientConfig {
|
||||
client_id: credentials.client_id.clone(),
|
||||
client_secret: Some(client_secret.clone()),
|
||||
scopes: vec![],
|
||||
redirect_uri: redirect_uri.clone(),
|
||||
})?;
|
||||
}
|
||||
|
||||
// GitHub does not issue refresh tokens for OAuth apps; their access tokens are valid
|
||||
// until the user explicitly revokes them.
|
||||
//
|
||||
// As such, if we have an access token for a GitHub server, we must assume it's valid.
|
||||
if provider.as_ref().is_some_and(|p| p.issuer == GITHUB_ISSUER) {
|
||||
return Ok((AuthClient::new(reqwest::Client::new(), auth_manager), false));
|
||||
}
|
||||
|
||||
// Else, make sure we have an up-to-date access token.
|
||||
// We need to do this because our fork of rmcp does not properly detect expired tokens.
|
||||
// This is fixed in https://github.com/modelcontextprotocol/rust-sdk/pull/680
|
||||
//
|
||||
// Install the persisting credential store before refreshing so that
|
||||
// the refresh result is automatically written back to secure storage.
|
||||
install_persisting_credential_store(
|
||||
&mut auth_manager,
|
||||
client_secret,
|
||||
spawner.clone(),
|
||||
uuid,
|
||||
)
|
||||
.await;
|
||||
match auth_manager.refresh_token().await {
|
||||
Ok(_) => {
|
||||
return Ok((AuthClient::new(reqwest::Client::new(), auth_manager), false));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to refresh token: {e:#}");
|
||||
|
||||
// We didn't have a valid auth token _and_ we could not refresh it, so
|
||||
// we need to go through the OAuth flow again.
|
||||
oauth_state = OAuthState::new(resource_url, None).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we're in headless mode and we reach here, it means we either have no credentials
|
||||
// or the cached credentials failed to refresh. Block interactive OAuth in headless mode.
|
||||
if is_headless {
|
||||
if is_file_based {
|
||||
log::warn!(
|
||||
"File-based MCP server {uuid} requires OAuth authentication; \
|
||||
skipping in headless mode. To use this server, authenticate it \
|
||||
in the Warp desktop app first."
|
||||
);
|
||||
}
|
||||
return Err(AuthError::AuthorizationFailed(
|
||||
"MCP server requires OAuth authentication. Please authenticate this server in the \
|
||||
Warp desktop app first, then try again."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Start the authorization process with our custom redirect URI
|
||||
oauth_state
|
||||
.start_authorization(&[], &redirect_uri, Some("Warp"))
|
||||
.await?;
|
||||
|
||||
let OAuthState::Session(AuthorizationSession {
|
||||
mut auth_manager, ..
|
||||
}) = oauth_state
|
||||
else {
|
||||
return Err(AuthError::InternalError(
|
||||
"OAuth state is not in the expected state".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// With DCR (Dynamic Client Registration), we don't pass in explicit scopes; they are specified
|
||||
// during dynamic registration.
|
||||
//
|
||||
// For apps for which we have static client IDs (e.g. GitHub), we manually override scopes.
|
||||
let mut scopes: &[&str] = &[];
|
||||
|
||||
let config = match auth_manager.register_client("Warp", &redirect_uri).await {
|
||||
Ok(config) => config,
|
||||
Err(err @ AuthError::RegistrationFailed(_)) => {
|
||||
// If we failed dynamic registration, check to see if this is an auth
|
||||
// server we have a static client ID for.
|
||||
|
||||
// TODO(vorporeal): adjust APIs in rmcp so that we don't need to make this redundant
|
||||
// discover_metadata() call (as it gets made within start_authorization() but we can't
|
||||
// look at the results).
|
||||
let metadata = auth_manager.discover_metadata().await?;
|
||||
let provider = metadata
|
||||
.issuer
|
||||
.as_deref()
|
||||
.and_then(ChannelState::mcp_oauth_provider_by_issuer)
|
||||
.ok_or(err)?;
|
||||
|
||||
if provider.issuer == GITHUB_ISSUER {
|
||||
scopes = &GITHUB_OAUTH_SCOPES;
|
||||
}
|
||||
|
||||
OAuthClientConfig {
|
||||
client_id: provider.client_id.into_owned(),
|
||||
client_secret: Some(provider.client_secret.into_owned()),
|
||||
redirect_uri: redirect_uri.clone(),
|
||||
// This `scopes` field appears to be unused by rmcp as of 9/17/25 - we pass scopes
|
||||
// in construction of the authorization url below.
|
||||
scopes: vec![],
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let client_secret = config.client_secret.clone();
|
||||
auth_manager.configure_client(config)?;
|
||||
|
||||
let auth_url = auth_manager.get_authorization_url(scopes).await?;
|
||||
oauth_state = OAuthState::Session(AuthorizationSession {
|
||||
auth_manager,
|
||||
auth_url: auth_url.clone(),
|
||||
redirect_uri,
|
||||
});
|
||||
|
||||
// Extract the CSRF token that rmcp embedded as the `state` query parameter in the
|
||||
// authorization URL. We register a csrf→uuid mapping on the manager so that
|
||||
// `handle_oauth_callback` can route the callback to the right server without
|
||||
// relying on `server_id` being present in the redirect URI.
|
||||
let csrf_state = Url::parse(&auth_url)
|
||||
.ok()
|
||||
.and_then(|u| {
|
||||
u.query_pairs()
|
||||
.find(|(k, _)| k == "state")
|
||||
.map(|(_, v)| v.into_owned())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Err(e) = spawner
|
||||
.spawn(move |manager, ctx| {
|
||||
if !csrf_state.is_empty() {
|
||||
manager.pending_oauth_csrf.insert(csrf_state, uuid);
|
||||
}
|
||||
ctx.open_url(&auth_url);
|
||||
manager.change_server_state(uuid, MCPServerState::Authenticating, ctx);
|
||||
})
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to emit RequiresAuthentication state: {e:?}");
|
||||
}
|
||||
|
||||
// Wait for the authorization code from the OAuth callback channel.
|
||||
let oauth_result = oauth_result_rx
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|e| AuthError::InternalError(e.to_string()))?;
|
||||
|
||||
let (code, csrf_token) = match &oauth_result {
|
||||
CallbackResult::Success { code, csrf_token } => (code, csrf_token),
|
||||
CallbackResult::Error { error } => {
|
||||
return Err(AuthError::AuthorizationFailed(
|
||||
error.as_deref().unwrap_or("unknown error").to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle the callback with the received authorization code and CSRF token.
|
||||
oauth_state.handle_callback(code, csrf_token).await?;
|
||||
|
||||
// Save the credentials to secure storage.
|
||||
let (client_id, token_response) = oauth_state.get_credentials().await?;
|
||||
if let Some(token_response) = token_response {
|
||||
let credentials = PersistedCredentials {
|
||||
client_id,
|
||||
client_secret: client_secret.clone(),
|
||||
token_response,
|
||||
};
|
||||
spawner
|
||||
.spawn(move |manager, ctx| {
|
||||
manager.save_credentials_to_secure_storage(ctx, uuid, credentials);
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AuthError::InternalError(e.to_string()))?;
|
||||
}
|
||||
|
||||
let mut am = oauth_state.into_authorization_manager().ok_or_else(|| {
|
||||
AuthError::InternalError("Failed to create authorization manager".to_string())
|
||||
})?;
|
||||
|
||||
install_persisting_credential_store(&mut am, client_secret, spawner, uuid).await;
|
||||
|
||||
Ok((AuthClient::new(reqwest::Client::new(), am), true))
|
||||
}
|
||||
|
||||
impl TemplatableMCPServerManager {
|
||||
/// Handles an incoming OAuth callback URL.
|
||||
///
|
||||
/// Routes the callback to the correct in-flight OAuth flow using the `state` query
|
||||
/// parameter (the CSRF token that rmcp embedded in the authorization URL). This avoids
|
||||
/// encoding routing data in the redirect URI, keeping it RFC 6749 §3.1.2.2 compliant.
|
||||
pub fn handle_oauth_callback(&mut self, url: &Url) -> anyhow::Result<()> {
|
||||
// Ensure the URL has the expected path
|
||||
if url.path() != "/oauth2callback" {
|
||||
bail!(
|
||||
"Invalid OAuth callback path: expected '/oauth2callback', got '{}'",
|
||||
url.path()
|
||||
);
|
||||
}
|
||||
|
||||
let query_params: HashMap<_, _> = url.query_pairs().collect();
|
||||
|
||||
let Some(state) = query_params.get("state") else {
|
||||
bail!("Missing 'state' parameter in OAuth callback");
|
||||
};
|
||||
|
||||
let code = query_params.get("code");
|
||||
let error = query_params.get("error");
|
||||
|
||||
let result = match code {
|
||||
Some(code) => CallbackResult::Success {
|
||||
code: code.to_string(),
|
||||
// Pass the state value through as the CSRF token; rmcp will validate it
|
||||
// against the token it stored when generating the authorization URL.
|
||||
csrf_token: state.to_string(),
|
||||
},
|
||||
None => CallbackResult::Error {
|
||||
error: error.map(|e| e.to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let Some(&server_uuid) = self.pending_oauth_csrf.get(state.as_ref() as &str) else {
|
||||
bail!("No active OAuth flow found for state={state}");
|
||||
};
|
||||
|
||||
let Some(server_info) = self.spawned_servers.get(&server_uuid) else {
|
||||
bail!("No spawned server found for uuid={server_uuid}");
|
||||
};
|
||||
|
||||
warpui::r#async::block_on(server_info.oauth_result_tx.send(result)).map_err(|_| {
|
||||
anyhow!("Failed to send OAuth result to server {server_uuid} - receiver dropped")
|
||||
})?;
|
||||
|
||||
self.pending_oauth_csrf.remove(state.as_ref() as &str);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_credentials_to_secure_storage(
|
||||
&mut self,
|
||||
app: &mut warpui::AppContext,
|
||||
installation_uuid: Uuid,
|
||||
credentials: PersistedCredentials,
|
||||
) {
|
||||
if let Some(hash) = FileBasedMCPManager::as_ref(app).get_hash_by_uuid(installation_uuid) {
|
||||
self.file_based_server_credentials.insert(hash, credentials);
|
||||
write_to_secure_storage(
|
||||
app,
|
||||
FILE_BASED_MCP_CREDENTIALS_KEY,
|
||||
&self.file_based_server_credentials,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(template_uuid) = self.get_template_uuid(installation_uuid) {
|
||||
self.server_credentials.insert(template_uuid, credentials);
|
||||
write_to_secure_storage(
|
||||
app,
|
||||
TEMPLATABLE_MCP_CREDENTIALS_KEY,
|
||||
&self.server_credentials,
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"Corresponding file or cloud-based server not found for installation UUID {installation_uuid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_credentials_from_secure_storage(
|
||||
&mut self,
|
||||
installation_uuid: Uuid,
|
||||
app: &mut warpui::AppContext,
|
||||
) {
|
||||
if let Some(template_uuid) = self.get_template_uuid(installation_uuid) {
|
||||
self.server_credentials.remove(&template_uuid);
|
||||
write_to_secure_storage(
|
||||
app,
|
||||
TEMPLATABLE_MCP_CREDENTIALS_KEY,
|
||||
&self.server_credentials,
|
||||
);
|
||||
} else {
|
||||
log::error!("No template UUID found for installation UUID {installation_uuid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads credentials from secure storage at the provided key.
|
||||
pub(crate) fn load_credentials_from_secure_storage<T: DeserializeOwned + Default>(
|
||||
app: &mut warpui::AppContext,
|
||||
key: &str,
|
||||
) -> T {
|
||||
app.secure_storage()
|
||||
.read_value(key)
|
||||
.inspect_err(|err| {
|
||||
if !matches!(err, warpui_extras::secure_storage::Error::NotFound) {
|
||||
log::warn!("Failed to read MCP credentials from secure storage: {err:#}");
|
||||
}
|
||||
})
|
||||
.ok()
|
||||
.and_then(|value| serde_json::from_str(&value).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Writes credentials to secure storage at the provided key.
|
||||
pub(crate) fn write_to_secure_storage<T: Serialize>(
|
||||
app: &mut warpui::AppContext,
|
||||
key: &str,
|
||||
credentials: &T,
|
||||
) {
|
||||
match serde_json::to_string(credentials) {
|
||||
Ok(json) => {
|
||||
app.secure_storage()
|
||||
.write_value(key, &json)
|
||||
.inspect_err(|err| {
|
||||
log::error!("Failed to write MCP credentials to secure storage: {err:#}")
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to serialize MCP credentials for secure storage: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use warpui::AppContext;
|
||||
use warpui::ModelContext;
|
||||
|
||||
use super::TemplatableMCPServerManager;
|
||||
use crate::ai::mcp::templatable::{CloudTemplatableMCPServer, TemplatableMCPServer};
|
||||
use crate::ai::mcp::templatable_installation::{TemplatableMCPServerInstallation, VariableValue};
|
||||
use crate::ai::mcp::MCPServerUpdate;
|
||||
use crate::cloud_object::Space;
|
||||
use crate::server::cloud_objects::update_manager::InitiatedBy;
|
||||
use crate::server::ids::ServerId;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
impl TemplatableMCPServerManager {
|
||||
/// Creates a new [`TemplatableMCPServerManager`] instance.
|
||||
pub fn new(
|
||||
_locally_installed_servers: HashMap<Uuid, TemplatableMCPServerInstallation>,
|
||||
_running_server_uuids: Vec<Uuid>,
|
||||
_running_legacy_servers: &[Uuid],
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// Gets a CloudTemplatableMCPServer by its UUID.
|
||||
/// Returns the CloudTemplatableMCPServer model if found, otherwise None.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn get_cloud_templatable_mcp_server(
|
||||
&self,
|
||||
_uuid: Uuid,
|
||||
) -> Option<&CloudTemplatableMCPServer> {
|
||||
log::warn!("Getting a CloudTemplatableMCPServer by UUID is not supported in WASM");
|
||||
None
|
||||
}
|
||||
|
||||
/// Gets a creator for a TemplatableMCPServer by its UUID.
|
||||
/// Returns the creator if found, otherwise None.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn get_creator(&self, _uuid: Uuid, _app: &AppContext) -> Option<String> {
|
||||
log::warn!("Getting a creator for a TemplatableMCPServer by UUID is not supported in WASM");
|
||||
None
|
||||
}
|
||||
|
||||
/// Updates a TemplatableMCPServer in Warp Drive.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn update_templatable_mcp_server(
|
||||
&mut self,
|
||||
_server: TemplatableMCPServer,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("Templatable MCP server update not supported in WASM");
|
||||
}
|
||||
|
||||
/// Gets all TemplatableMCPServers currently in Warp Drive.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn get_all_templatable_mcp_servers(&self) -> Vec<&TemplatableMCPServer> {
|
||||
log::warn!("Getting all TemplatableMCPServers is not supported in WASM");
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Gets a TemplatableMCPServer by its UUID.
|
||||
/// Returns the TemplatableMCPServer model if found, otherwise None.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn get_templatable_mcp_server(&self, _uuid: Uuid) -> Option<&TemplatableMCPServer> {
|
||||
log::warn!("Getting a TemplatableMCPServer by UUID is not supported in WASM");
|
||||
None
|
||||
}
|
||||
|
||||
/// Creates a new TemplatableMCPServer in Warp Drive.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn create_templatable_mcp_server(
|
||||
&mut self,
|
||||
_server: TemplatableMCPServer,
|
||||
_space: Space,
|
||||
_initiated_by: InitiatedBy,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("Creating a TemplatableMCPServer is not supported in WASM");
|
||||
}
|
||||
|
||||
/// Deletes a TemplatableMCPServer from Warp Drive.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn delete_templatable_mcp_server(&mut self, _uuid: Uuid, _ctx: &mut ModelContext<Self>) {
|
||||
log::warn!("Deleting a TemplatableMCPServer is not supported in WASM");
|
||||
}
|
||||
|
||||
/// Spawns a new MCP server from a given [`TemplatableMCPServer`] instance.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn spawn_server(&mut self, _uuid: Uuid, _ctx: &mut ModelContext<Self>) {
|
||||
log::warn!("MCP server spawning not supported in WASM");
|
||||
}
|
||||
|
||||
/// Spawns a CLI-spawned ephemeral MCP server.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
#[allow(dead_code)]
|
||||
pub fn spawn_cli_ephemeral_server(
|
||||
&mut self,
|
||||
_installation: TemplatableMCPServerInstallation,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("Ephemeral MCP server spawning not supported in WASM");
|
||||
}
|
||||
|
||||
/// Shuts down a running MCP server.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn shutdown_server(&mut self, _uuid: Uuid, _ctx: &mut ModelContext<Self>) {
|
||||
log::warn!("MCP server shutdown not supported in WASM");
|
||||
}
|
||||
|
||||
/// Deletes a locally installed MCP server installation.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn delete_templatable_mcp_server_installation(
|
||||
&mut self,
|
||||
_installation_uuid: Uuid,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("Templatable MCP server installation deletion not supported in WASM");
|
||||
}
|
||||
|
||||
pub fn install_from_template(
|
||||
&mut self,
|
||||
_templatable_mcp_server: TemplatableMCPServer,
|
||||
_variable_values: HashMap<String, VariableValue>,
|
||||
_start_automatically: bool,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> Option<TemplatableMCPServerInstallation> {
|
||||
log::warn!("Templatable MCP server installation not supported in WASM");
|
||||
None
|
||||
}
|
||||
|
||||
/// Enables (starts) the installed Figma MCP server.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn enable_figma_mcp(&mut self, _ctx: &mut ModelContext<Self>) {
|
||||
log::warn!("Enabling Figma MCP server is not supported in WASM");
|
||||
}
|
||||
|
||||
/// Installs the Figma MCP server from the MCP gallery.
|
||||
///
|
||||
/// This is a no-op in WASM, as MCP servers are not supported in WASM.
|
||||
pub fn install_figma_from_gallery(&mut self, _ctx: &mut ModelContext<Self>) {
|
||||
log::warn!("Installing Figma from gallery is not supported in WASM");
|
||||
}
|
||||
|
||||
/// Delete oauth credentials from secure storage
|
||||
///
|
||||
/// No-op in WASM, as MCP servers are not supported in WASM
|
||||
pub fn delete_credentials_from_secure_storage(
|
||||
&mut self,
|
||||
_sync_id: Uuid,
|
||||
_app: &mut warpui::AppContext,
|
||||
) {
|
||||
log::warn!("Deleting credentials for MCP servers is not supported in WASM")
|
||||
}
|
||||
|
||||
pub fn is_server_installation_shared(
|
||||
&self,
|
||||
_installation_uuid: Uuid,
|
||||
_app: &AppContext,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn is_server_template_shared(&self, _template_uuid: Uuid, _app: &AppContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn get_cloud_server(
|
||||
&self,
|
||||
_template_uuid: Uuid,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> Option<&CloudTemplatableMCPServer> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_update_available_for_installation(
|
||||
&self,
|
||||
_installation_uuid: Uuid,
|
||||
_app: &AppContext,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn get_updates_available_for_installation(
|
||||
&self,
|
||||
_installation_uuid: Uuid,
|
||||
_app: &AppContext,
|
||||
) -> Vec<MCPServerUpdate> {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
pub fn update_templatable_mcp_server_installation(
|
||||
&mut self,
|
||||
_installation_uuid: Uuid,
|
||||
_templatable_mcp_server: &TemplatableMCPServer,
|
||||
_reuse_variable_values: bool,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("Updating a templatable MCP server installation is not supported in WASM");
|
||||
}
|
||||
|
||||
pub fn is_authorized_editor(&self, _template_uuid: Uuid, _ctx: &AppContext) -> bool {
|
||||
log::warn!(
|
||||
"Checking if a user is authorized to edit a templatable MCP server is not supported in WASM"
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
pub fn is_author(&self, _template_uuid: Uuid, _ctx: &AppContext) -> bool {
|
||||
log::warn!(
|
||||
"Checking if a user is the author of a templatable MCP server is not supported in WASM"
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
pub fn share_templatable_mcp_server(
|
||||
&mut self,
|
||||
_template_uuid: Uuid,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("Sharing a templatable MCP server is not supported in WASM");
|
||||
}
|
||||
|
||||
pub fn share_templatable_mcp_server_installation(
|
||||
&mut self,
|
||||
_installation_uuid: Uuid,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("Sharing a templatable MCP server installation is not supported in WASM");
|
||||
}
|
||||
|
||||
pub fn unshare_templatable_mcp_server(
|
||||
&mut self,
|
||||
_template_uuid: Uuid,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("Unsharing a templatable MCP server is not supported in WASM");
|
||||
}
|
||||
|
||||
pub fn unshare_templatable_mcp_server_installation(
|
||||
&mut self,
|
||||
_installation_uuid: Uuid,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("Unsharing a templatable MCP server installation is not supported in WASM");
|
||||
}
|
||||
|
||||
pub fn get_first_team_space_id(_app: &AppContext) -> Option<ServerId> {
|
||||
log::warn!("Getting the first team space ID is not supported in WASM");
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_installation_by_template_uuid(
|
||||
&self,
|
||||
_template_uuid: Uuid,
|
||||
) -> Option<&TemplatableMCPServerInstallation> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_all_cloud_synced_mcp_servers(_ctx: &AppContext) -> HashMap<Uuid, String> {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
pub fn get_mcp_name(_uuid: &Uuid, _app: &AppContext) -> Option<String> {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
pub fn has_oauth_credentials_for_server(&self, _template_uuid: Uuid) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn spawn_ephemeral_server(
|
||||
&mut self,
|
||||
_installation: TemplatableMCPServerInstallation,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("Ephemeral MCP server spawning not supported in WASM");
|
||||
}
|
||||
|
||||
pub fn purge_file_based_server_credentials(
|
||||
&mut self,
|
||||
_installation_hashes: &Vec<u64>,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::warn!("Purging file-based server credentials not supported in WASM");
|
||||
}
|
||||
|
||||
pub fn has_oauth_credentials_for_file_based_server(&self, _hash: u64) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn extract_server_info<T: std::cmp::Eq + std::hash::Hash>(
|
||||
&self,
|
||||
_template_fn: fn(&TemplatableMCPServer) -> Option<T>,
|
||||
_installation_fn: fn(&TemplatableMCPServerInstallation) -> Option<T>,
|
||||
_app: &AppContext,
|
||||
) -> HashSet<T> {
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user