Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -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 {}
+15
View File
@@ -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 {}
+433
View File
@@ -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;
+513
View File
@@ -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"
);
});
});
}
+747
View File
@@ -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;
+74
View File
@@ -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"]);
}
+196
View File
@@ -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 {}
+29
View File
@@ -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}",
))
})
}
+13
View File
@@ -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))
}
+6
View File
@@ -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;
+7
View File
@@ -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";
+713
View File
@@ -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
+406
View File
@@ -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;
+90
View File
@@ -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");
}
+137
View File
@@ -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
}
}
+307
View File
@@ -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
}
}
+171
View File
@@ -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)
}
}
+364
View File
@@ -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
+571
View File
@@ -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:#}");
}
}
}
+311
View File
@@ -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()
}
}