first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+3 -2
View File
@@ -1,9 +1,10 @@
use crate::ai::mcp::templatable_installation::TemplatableMCPServerInstallation;
use std::path::{Path, PathBuf};
use uuid::Uuid;
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
use super::MCPProvider;
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use crate::ai::mcp::templatable_installation::TemplatableMCPServerInstallation;
pub struct FileBasedMCPManager {}
+161 -59
View File
@@ -1,21 +1,20 @@
use super::MCPProvider;
use super::{FileMCPWatcher, FileMCPWatcherEvent};
use galaxy_core::features::FeatureFlag;
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
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 galaxy_core::features::FeatureFlag;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use crate::{
ai::mcp::{
templatable_installation::TemplatableMCPServerInstallation,
ParsedTemplatableMCPServerResult,
},
galaxy_managed_paths_watcher::galaxy_data_dir,
settings::{ai::AISettings, AISettingsChangedEvent},
};
use super::{FileMCPWatcher, FileMCPWatcherEvent, MCPProvider};
use crate::ai::mcp::templatable_installation::TemplatableMCPServerInstallation;
use crate::ai::mcp::ParsedTemplatableMCPServerResult;
use crate::settings::ai::AISettings;
use crate::settings::AISettingsChangedEvent;
use crate::warp_managed_paths_watcher::warp_managed_mcp_config_path;
/// Singleton model to manage file-based MCP servers.
#[derive(Default)]
@@ -25,16 +24,20 @@ pub struct FileBasedMCPManager {
file_based_servers: HashMap<u64, TemplatableMCPServerInstallation>,
/// Reverse mapping: logical root path → provider → set of server hashes.
file_based_servers_by_root: HashMap<PathBuf, HashMap<MCPProvider, HashSet<u64>>>,
/// UUIDs that were actually auto-start requested while parsing each `(root, provider)`.
/// They are temporarily stored here and removed to emit FileBasedMCPManagerEvent::CloudEnvMcpScanComplete
pending_scan_auto_started_servers_by_root:
HashMap<PathBuf, HashMap<MCPProvider, HashSet<Uuid>>>,
}
impl FileBasedMCPManager {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
if FeatureFlag::FileBasedMcp.is_enabled() {
ctx.subscribe_to_model(&FileMCPWatcher::handle(ctx), |me, event, ctx| {
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| {
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, ctx| {
if matches!(event, AISettingsChangedEvent::FileBasedMcpEnabled { .. }) {
me.handle_file_based_mcp_enabled_change(ctx);
}
@@ -44,6 +47,7 @@ impl FileBasedMCPManager {
Self {
file_based_servers: Default::default(),
file_based_servers_by_root: Default::default(),
pending_scan_auto_started_servers_by_root: Default::default(),
}
}
@@ -75,7 +79,9 @@ impl FileBasedMCPManager {
cwd: &Path,
app: &AppContext,
) -> Vec<&TemplatableMCPServerInstallation> {
let repo_root = DetectedRepositories::as_ref(app).get_root_for_path(cwd);
let repo_root = DetectedRepositories::as_ref(app)
.get_root_for_path(&LocalOrRemotePath::Local(cwd.to_path_buf()))
.and_then(|r| PathBuf::try_from(r).ok());
let candidate_roots = [dirs::home_dir(), repo_root];
let mut servers = Vec::new();
@@ -195,8 +201,11 @@ impl FileBasedMCPManager {
scanned_servers.insert(hash);
}
// If file-based MCP is enabled, spawn any new servers.
self.spawn_file_based_servers(servers_to_spawn, ctx);
let auto_started_uuids = self.maybe_autostart_file_based_servers(servers_to_spawn, ctx);
self.pending_scan_auto_started_servers_by_root
.entry(root_path.clone())
.or_default()
.insert(provider, auto_started_uuids.into_iter().collect());
// Determine which servers have been removed.
let servers_to_remove = previous_scanned_servers
@@ -235,7 +244,7 @@ impl FileBasedMCPManager {
/// config location.
///
/// "Global" means the installation was detected outside of a user repository:
/// - For `MCPProvider::Warp`: `galaxy_data_dir()` (i.e. `~/.warp-core/.mcp.json`).
/// - For `MCPProvider::Warp`: the logical root for `~/.warp*/.mcp.json`.
/// - For any other provider: the user's home directory (e.g. `~/.claude.json`).
///
/// Project-scoped installations (those detected inside a repo) are not considered
@@ -243,7 +252,6 @@ impl FileBasedMCPManager {
/// 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 = galaxy_data_dir();
self.file_based_servers_by_root
.iter()
.any(|(root_path, provider_map)| {
@@ -252,7 +260,7 @@ impl FileBasedMCPManager {
return false;
}
match provider {
MCPProvider::Warp => root_path == &warp_root,
MCPProvider::Warp => Self::is_global_warp_root(root_path),
MCPProvider::Claude | MCPProvider::Codex | MCPProvider::Agents => {
home_dir.as_ref().is_some_and(|home| root_path == home)
}
@@ -264,20 +272,47 @@ impl FileBasedMCPManager {
/// 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 = galaxy_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))
.iter()
.any(|(root_path, provider_map)| {
Self::is_global_warp_root(root_path)
&& provider_map
.get(&MCPProvider::Warp)
.is_some_and(|hashes| hashes.contains(&hash))
})
}
fn spawn_file_based_servers(
fn is_global_warp_root(root_path: &Path) -> bool {
warp_managed_mcp_config_path().is_some_and(|path| root_path == path.root_path.as_path())
}
fn auto_start_decision(&self, hash: u64, file_based_mcp_enabled: bool) -> AutoStartDecision {
let server_type = if self.is_global_warp_server(hash) {
FileBasedMCPServerType::GlobalWarp
} else if self.is_global_server(hash) {
FileBasedMCPServerType::GlobalThirdParty
} else {
FileBasedMCPServerType::ProjectScoped
};
let should_autostart = match server_type {
FileBasedMCPServerType::GlobalWarp => true,
FileBasedMCPServerType::GlobalThirdParty => file_based_mcp_enabled,
FileBasedMCPServerType::ProjectScoped => false,
};
AutoStartDecision {
should_autostart,
server_type,
}
}
/// Returns the UUIDs of servers that were actually auto-started.
fn maybe_autostart_file_based_servers(
&mut self,
servers_to_spawn: Vec<TemplatableMCPServerInstallation>,
servers_to_consider: Vec<TemplatableMCPServerInstallation>,
ctx: &mut ModelContext<Self>,
) {
if servers_to_spawn.is_empty() {
return;
) -> Vec<Uuid> {
if servers_to_consider.is_empty() {
return Vec::new();
}
let mcp_enabled = AISettings::as_ref(ctx).is_file_based_mcp_enabled(ctx);
@@ -287,15 +322,23 @@ impl FileBasedMCPManager {
// - 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 mut auto_started_uuids = Vec::new();
for installation in servers_to_consider {
let Some(hash) = installation.hash() else {
continue;
};
if self.is_global_warp_server(hash) || (self.is_global_server(hash) && mcp_enabled) {
let installation_uuid = installation.uuid();
let server_name = installation.templatable_mcp_server().name.clone();
let AutoStartDecision {
should_autostart, ..
} = self.auto_start_decision(hash, mcp_enabled);
if should_autostart {
log::info!(
"Auto-spawning file-based MCP server '{server_name}' ({installation_uuid})"
);
auto_started_uuids.push(installation_uuid);
to_spawn.push(installation);
}
// Project-scoped installations are intentionally dropped from auto-spawn.
}
if !to_spawn.is_empty() {
@@ -303,6 +346,7 @@ impl FileBasedMCPManager {
installations: to_spawn,
});
}
auto_started_uuids
}
fn handle_cloud_environment_scan_complete(
@@ -310,24 +354,54 @@ impl FileBasedMCPManager {
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();
let mcp_enabled = AISettings::as_ref(ctx).is_file_based_mcp_enabled(ctx);
// FileMCPWatcher emits CloudEnvMcpScanComplete only after emitting ConfigParsed
// for every provider config in this repo scan. Each ConfigParsed call records
// the UUIDs actually emitted through SpawnServers in
// pending_scan_auto_started_servers_by_root, so this remove() returns the wait set
// for this completed scan.
let wait_server_uuids: Vec<Uuid> = self
.pending_scan_auto_started_servers_by_root
.remove(repo_path)
.into_iter()
.flat_map(|provider_map| provider_map.into_values())
.flatten()
.sorted_by_key(|uuid| uuid.to_string())
.collect();
// Pass the UUIDs of all detected file-based MCP servers to the AgentDriver.
let mut detected_servers: Vec<CloudEnvMcpScanServer> = Vec::new();
if let Some(provider_map) = self.file_based_servers_by_root.get(repo_path) {
for (provider, hash_set) in provider_map {
for hash in hash_set {
let Some(installation) = self.file_based_servers.get(hash) else {
continue;
};
let uuid = installation.uuid();
let auto_start_eligible = self
.auto_start_decision(*hash, mcp_enabled)
.should_autostart;
detected_servers.push(CloudEnvMcpScanServer {
uuid,
name: installation.templatable_mcp_server().name.clone(),
provider: *provider,
hash: *hash,
auto_start_eligible,
});
}
}
}
log::info!(
"Cloud environment file-based MCP scan complete for {}: {} detected server(s), {} auto-started server(s)",
repo_path.display(),
detected_servers.len(),
wait_server_uuids.len()
);
// Pass the UUIDs of auto-start-requested file-based MCP servers to the AgentDriver.
ctx.emit(FileBasedMCPManagerEvent::CloudEnvMcpScanComplete {
repo_path: repo_path.clone(),
server_uuids,
detected_servers,
wait_server_uuids,
});
}
@@ -341,7 +415,8 @@ impl FileBasedMCPManager {
.file_based_servers
.iter()
.filter(|(hash, _)| {
self.is_global_server(**hash) && !self.is_global_warp_server(**hash)
self.auto_start_decision(**hash, true).server_type
== FileBasedMCPServerType::GlobalThirdParty
})
.map(|(_, server)| server.clone())
.collect();
@@ -409,9 +484,9 @@ impl FileBasedMCPManager {
/// when its config does not specify `working_directory`.
///
/// The spawn root is the directory the config was discovered in, with one
/// exception: global Warp installs are discovered in `~/.warp-core/` (Warp's data
/// dir) which isn't a useful cwd for spawned processes, so they are remapped
/// to the home directory instead.
/// exception: global Warp installs are discovered in `~/.warp*/`, which
/// isn't a useful cwd for spawned processes, so they are remapped to the
/// home directory instead.
/// - Project-scoped installations: the repo root.
/// - Global installations (`~/.warp-core/.mcp.json`, `~/.claude.json`, etc.): the
/// home directory.
@@ -429,16 +504,41 @@ impl FileBasedMCPManager {
.sorted()
.next()?;
// Global Warp installs live under `~/.warp-core/`, which is internal Warp state
// rather than a meaningful working directory. Map them to the home dir so
// all global installs (Warp and third-party) share a consistent cwd.
if discovery_root == galaxy_data_dir() {
// Global Warp installs live under `~/.warp*/`, which is internal Warp
// state rather than a meaningful working directory. Map them to the
// home dir so all global installs (Warp and third-party) share a
// consistent cwd.
if self.is_global_warp_server(hash) {
return dirs::home_dir().or(Some(discovery_root));
}
Some(discovery_root)
}
}
struct AutoStartDecision {
should_autostart: bool,
server_type: FileBasedMCPServerType,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FileBasedMCPServerType {
/// A file-based MCP server detected from Warp's global managed config.
GlobalWarp,
/// A file-based MCP server detected from a non-Warp provider's global user config.
GlobalThirdParty,
/// A file-based MCP server detected from a project/repository-scoped config.
ProjectScoped,
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub struct CloudEnvMcpScanServer {
pub uuid: Uuid,
pub name: String,
pub provider: MCPProvider,
pub hash: u64,
pub auto_start_eligible: bool,
}
pub enum FileBasedMCPManagerEvent {
SpawnServers {
installations: Vec<TemplatableMCPServerInstallation>,
@@ -451,7 +551,9 @@ pub enum FileBasedMCPManagerEvent {
},
CloudEnvMcpScanComplete {
repo_path: PathBuf,
server_uuids: Vec<Uuid>,
#[allow(dead_code)]
detected_servers: Vec<CloudEnvMcpScanServer>,
wait_server_uuids: Vec<Uuid>,
},
}
+121 -25
View File
@@ -1,21 +1,20 @@
use super::{FileBasedMCPManager, FileBasedMCPManagerEvent, MCPProvider};
use crate::ai::mcp::FileMCPWatcher;
use crate::ai::mcp::ParsedTemplatableMCPServerResult;
use crate::auth::AuthStateProvider;
use crate::galaxy_managed_paths_watcher::{galaxy_data_dir, GalaxyManagedPathsWatcher};
use crate::settings::{AISettings, FocusedTerminalInfo};
use crate::workspaces::user_workspaces::UserWorkspaces;
use galaxy_core::features::FeatureFlag;
use galaxyui::{App, Entity, ModelHandle, SingletonEntity as _};
use repo_metadata::{
repositories::DetectedRepositories, watcher::DirectoryWatcher, RepoMetadataModel,
};
use settings::Setting as _;
use std::collections::HashSet;
use std::path::PathBuf;
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::watcher::DirectoryWatcher;
use repo_metadata::RepoMetadataModel;
use settings::Setting as _;
use uuid::Uuid;
use watcher::HomeDirectoryWatcher;
use super::{CloudEnvMcpScanServer, FileBasedMCPManager, FileBasedMCPManagerEvent, MCPProvider};
use crate::ai::mcp::{FileMCPWatcher, ParsedTemplatableMCPServerResult};
use crate::auth::AuthStateProvider;
use crate::settings::{AISettings, FocusedTerminalInfo};
use crate::warp_managed_paths_watcher::{warp_managed_mcp_config_path, WarpManagedPathsWatcher};
use crate::workspaces::user_workspaces::UserWorkspaces;
// Helper to initialize dependencies and return FileBasedMCPManager handle
fn setup_app(app: &mut App) -> galaxyui::ModelHandle<FileBasedMCPManager> {
app.add_singleton_model(DirectoryWatcher::new);
@@ -42,6 +41,14 @@ fn parse_mcp_json(json: &str) -> Vec<ParsedTemplatableMCPServerResult> {
struct ManagerEvents {
spawned_uuids: Vec<Uuid>,
despawned_uuids: Vec<Uuid>,
scan_completions: Vec<ScanCompletion>,
}
#[derive(Clone, Debug)]
struct ScanCompletion {
repo_path: PathBuf,
detected_servers: Vec<CloudEnvMcpScanServer>,
wait_server_uuids: Vec<Uuid>,
}
impl Entity for ManagerEvents {
@@ -55,7 +62,7 @@ fn subscribe_events(
) -> ModelHandle<ManagerEvents> {
let events = app.add_model(|_| ManagerEvents::default());
events.update(app, |_, ctx| {
ctx.subscribe_to_model(manager, |me, event, _| match event {
ctx.subscribe_to_model(manager, |me, _, event, _| match event {
FileBasedMCPManagerEvent::SpawnServers { installations } => me
.spawned_uuids
.extend(installations.iter().map(|i| i.uuid())),
@@ -63,8 +70,18 @@ fn subscribe_events(
me.despawned_uuids
.extend(installation_uuids.iter().copied());
}
FileBasedMCPManagerEvent::PurgeCredentials { .. }
| FileBasedMCPManagerEvent::CloudEnvMcpScanComplete { .. } => {}
FileBasedMCPManagerEvent::PurgeCredentials { .. } => {}
FileBasedMCPManagerEvent::CloudEnvMcpScanComplete {
repo_path,
detected_servers,
wait_server_uuids,
} => {
me.scan_completions.push(ScanCompletion {
repo_path: repo_path.clone(),
detected_servers: detected_servers.clone(),
wait_server_uuids: wait_server_uuids.clone(),
});
}
});
});
events
@@ -255,28 +272,37 @@ fn test_update_file_based_servers_removes_unreferenced_servers() {
});
}
/// A globally-scoped Warp installation always auto-spawns, regardless of the
/// `file_based_mcp_enabled` toggle.
/// A Warp-global installation detected from the managed `~/.warp*/.mcp.json`
/// watcher uses the home directory as its logical root and still always
/// auto-spawns.
#[test]
fn test_global_warp_server_always_spawns() {
fn test_global_warp_server_from_managed_home_root_always_spawns() {
let _flag_guard = FeatureFlag::FileBasedMcp.override_enabled(true);
let warp_root = galaxy_data_dir();
let Some(warp_mcp_config_path) = warp_managed_mcp_config_path() else {
return;
};
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.
// Toggle is off by default; the watcher-produced Warp root should still
// be classified as the global Warp config and auto-spawn.
manager.update(&mut app, |m, ctx| {
m.apply_parsed_servers(warp_root.clone(), MCPProvider::Warp, parsed, ctx);
m.apply_parsed_servers(
warp_mcp_config_path.root_path.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"
"Managed Warp MCP config should auto-spawn regardless of toggle"
);
});
@@ -287,13 +313,12 @@ fn test_global_warp_server_always_spawns() {
events.update(&mut app, |e, _| {
assert!(
e.despawned_uuids.is_empty(),
"Global Warp server should never be despawned by toggle changes, got: {:?}",
"Managed Warp MCP config 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() {
@@ -405,6 +430,77 @@ fn test_project_scoped_servers_never_auto_spawn() {
});
}
#[test]
fn test_project_scoped_cloud_scan_has_detected_servers_but_empty_wait_set() {
let _flag_guard = FeatureFlag::FileBasedMcp.override_enabled(true);
let repo_path = PathBuf::from("/tmp/warp-test-cloud-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);
m.handle_cloud_environment_scan_complete(&repo_path, ctx);
});
events.update(&mut app, |e, _| {
assert_eq!(e.scan_completions.len(), 1);
let scan = &e.scan_completions[0];
assert_eq!(scan.repo_path, repo_path);
assert_eq!(scan.detected_servers.len(), 2);
assert!(
scan.wait_server_uuids.is_empty(),
"Project-scoped servers must not be included in the AgentDriver wait set, got: {:?}",
scan.wait_server_uuids
);
assert!(
scan.detected_servers
.iter()
.all(|server| !server.auto_start_eligible),
"Project-scoped servers should not be auto-start eligible: {:?}",
scan.detected_servers
);
});
});
}
#[test]
fn test_auto_started_cloud_scan_uuids_are_in_wait_set() {
let _flag_guard = FeatureFlag::FileBasedMcp.override_enabled(true);
let Some(warp_mcp_config_path) = warp_managed_mcp_config_path() else {
return;
};
let root_path = warp_mcp_config_path.root_path;
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);
manager.update(&mut app, |m, ctx| {
m.apply_parsed_servers(root_path.clone(), MCPProvider::Warp, parsed, ctx);
m.handle_cloud_environment_scan_complete(&root_path, ctx);
});
events.update(&mut app, |e, _| {
assert_eq!(e.spawned_uuids.len(), 1);
assert_eq!(e.scan_completions.len(), 1);
let scan = &e.scan_completions[0];
assert_eq!(scan.detected_servers.len(), 1);
assert_eq!(scan.wait_server_uuids, e.spawned_uuids);
assert!(
scan.detected_servers[0].auto_start_eligible,
"Global Warp server should be auto-start eligible"
);
});
});
}
/// 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]
+25 -23
View File
@@ -1,29 +1,28 @@
use async_channel::Sender;
use futures::Future;
use galaxy_core::safe_warn;
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
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 async_channel::Sender;
use futures::Future;
use regex::Regex;
use repo_metadata::repositories::{
DetectedRepositories, DetectedRepositoriesEvent, RepoDetectionSource,
};
use repo_metadata::repository::{Repository, RepositorySubscriber, SubscriberId};
use repo_metadata::watcher::{DirectoryWatcher, RepositoryUpdate};
use strum::IntoEnumIterator;
use galaxy_core::safe_warn;
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use watcher::HomeDirectoryWatcherEvent;
use crate::ai::mcp::{
home_config_file_path, parsing::normalize_codex_toml_to_json, MCPProvider,
ParsedTemplatableMCPServerResult,
};
use crate::galaxy_managed_paths_watcher::{
galaxy_managed_mcp_config_path, GalaxyManagedPathsWatcher, GalaxyManagedPathsWatcherEvent,
use crate::ai::mcp::parsing::normalize_codex_toml_to_json;
use crate::ai::mcp::{home_config_file_path, 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"));
@@ -148,7 +147,7 @@ impl FileMCPWatcher {
// 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| {
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.
@@ -170,12 +169,15 @@ impl FileMCPWatcher {
});
// Subscribe to changes to top-level files in the home directory.
ctx.subscribe_to_model(&HomeDirectoryWatcher::handle(ctx), |me, event, ctx| {
ctx.subscribe_to_model(&HomeDirectoryWatcher::handle(ctx), |me, _, event, ctx| {
me.handle_home_directory_watcher_event(event, ctx);
});
ctx.subscribe_to_model(&GalaxyManagedPathsWatcher::handle(ctx), |me, event, ctx| {
me.handle_warp_managed_paths_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) = galaxy_managed_mcp_config_path() {
@@ -239,7 +241,7 @@ impl FileMCPWatcher {
}
let Some(repo_handle) =
DetectedRepositories::as_ref(ctx).get_watched_repo_for_path(&repo_path, ctx)
DetectedRepositories::as_ref(ctx).get_local_watched_repo_for_path(&repo_path, ctx)
else {
return;
};
+2 -1
View File
@@ -1,6 +1,7 @@
use super::substitute_env_vars;
use std::env;
use super::substitute_env_vars;
fn cleanup_env_vars(vars: &[&str]) {
for var in vars {
env::remove_var(var);
+5 -6
View File
@@ -1,13 +1,12 @@
use std::collections::HashMap;
use uuid::Uuid;
use warpui::{Entity, ModelContext, SingletonEntity};
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 galaxyui::{Entity, ModelContext, SingletonEntity};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct GalleryMCPServer {
@@ -83,7 +82,7 @@ impl TryFrom<GalleryMCPServer> for TemplatableMCPServer {
name: title,
description: Some(description),
template: json_template,
version: DateTime::now().timestamp(),
version: chrono::Local::now().timestamp(),
gallery_data: Some(GalleryData {
gallery_item_id: gallery_uuid,
version: gallery_version,
@@ -106,7 +105,7 @@ impl MCPGalleryManager {
// Subscribe to UpdateManager events to receive MCP gallery updates
let update_manager = UpdateManager::handle(ctx);
ctx.subscribe_to_model(&update_manager, |me, event, ctx| {
ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| {
if let UpdateManagerEvent::MCPGalleryUpdated { templates } = event {
me.update_gallery_items(templates.clone(), ctx);
}
-29
View File
@@ -1,29 +0,0 @@
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}",
))
})
}
+26 -1
View File
@@ -1,8 +1,24 @@
use simple_logger::manager::resolve_log_path;
use std::path::PathBuf;
use simple_logger::manager::resolve_log_path;
use uuid::Uuid;
/// Per-server MCP log rotation policy.
///
/// Caps each server's on-disk log footprint at `MCP_LOG_MAX_FILE_SIZE_BYTES *
/// (1 + MCP_LOG_MAX_ROTATION)` — one active file plus the rotated tail. For
/// the chosen values that's 10 MiB × 6 = 60 MiB per MCP server, which is far
/// below the 20 GB single-server growth observed in #7723 and large enough to
/// preserve a useful debugging window for a misbehaving server.
///
/// Gated to non-wasm: the only consumer is the native MCP manager, which is
/// itself `#[cfg(not(target_family = "wasm"))]`, so these would be dead code
/// on the wasm build.
#[cfg(not(target_family = "wasm"))]
const MCP_LOG_MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024;
#[cfg(not(target_family = "wasm"))]
const MCP_LOG_MAX_ROTATION: usize = 5;
pub fn relative_log_file_path_from_uuid(uuid: &Uuid) -> PathBuf {
PathBuf::from(format!("{uuid}.log"))
}
@@ -11,3 +27,12 @@ pub fn relative_log_file_path_from_uuid(uuid: &Uuid) -> PathBuf {
pub fn log_file_path_from_uuid(uuid: &Uuid) -> PathBuf {
resolve_log_path("mcp", relative_log_file_path_from_uuid(uuid))
}
/// Rotation policy applied to every MCP server log writer. Returns `None` only
/// if a future change accidentally sets one of the cap constants to zero;
/// callers can treat `None` as "rotation disabled" and the existing
/// truncate-on-create behavior is preserved.
#[cfg(not(target_family = "wasm"))]
pub fn mcp_log_rotation_config() -> Option<simple_logger::RotationConfig> {
simple_logger::RotationConfig::new(MCP_LOG_MAX_FILE_SIZE_BYTES, MCP_LOG_MAX_ROTATION)
}
+181 -319
View File
@@ -1,41 +1,33 @@
pub mod manager;
pub mod templatable_manager;
#[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 galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::Icon;
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use strum_macros::EnumIter;
pub mod manager;
pub mod templatable_manager;
#[cfg(not(target_family = "wasm"))]
pub use templatable_manager::McpIntegration;
pub use templatable_manager::TemplatableMCPServerManager;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::Icon;
use crate::cloud_object::model::generic_string_model::StringModel;
use crate::cloud_object::model::json_model::JsonModel;
use crate::cloud_object::{
CloudObjectUuid, GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType,
Revision,
};
use crate::drive::items::mcp_server::WarpDriveMCPServer;
use crate::drive::items::WarpDriveItem;
use crate::drive::CloudObjectTypeAndId;
#[cfg(not(target_family = "wasm"))]
use crate::persistence::model::MCPEnvironmentVariables;
use crate::server::ids::SyncId;
use crate::server::sync_queue::QueueItem;
cfg_if::cfg_if! {
if #[cfg(not(feature = "local_fs"))] {
@@ -46,13 +38,6 @@ cfg_if::cfg_if! {
}
}
pub(crate) fn home_config_file_path(provider: MCPProvider) -> Option<PathBuf> {
match provider {
MCPProvider::Warp => galaxy_core::paths::galaxy_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;
@@ -62,230 +47,32 @@ cfg_if::cfg_if! {
}
}
#[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-core/.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-core/.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(galaxy_home_mcp_config_file_path) =
galaxy_core::paths::galaxy_home_mcp_config_file_path()
{
assert_eq!(
mcp_provider_from_file_path(&galaxy_home_mcp_config_file_path),
Some(MCPProvider::Warp)
);
}
}
}
pub mod gallery;
use galaxyui::{AppContext, SingletonEntity as _};
pub use gallery::MCPGalleryManager;
pub mod templatable;
pub use templatable::JsonTemplate;
pub use templatable::{TemplatableMCPServer, TemplateVariable};
#[cfg(not(target_family = "wasm"))]
pub use cloud_object_models::{
CLIServer, JSONMCPServer, JSONTransportType, ServerSentEvents, StaticEnvVar, StaticHeader,
};
pub use cloud_object_models::{
CloudMCPServer, CloudMCPServerModel, MCPServer, MCPServerState, TransportType,
};
pub use templatable::{JsonTemplate, 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;
pub use parsing::ParsedTemplatableMCPServerResult;
#[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 CloudObjectUuid for MCPServer {
fn uuid(&self) -> uuid::Uuid {
self.uuid
}
}
@@ -328,13 +115,6 @@ impl StringModel for MCPServer {
}
}
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
}
@@ -419,6 +199,7 @@ fn items_from_hashmap<T: NameValuePair>(map: &HashMap<String, String>) -> Vec<T>
/// Converts a slice of name/value pair items to a HashMap.
#[cfg(not(target_family = "wasm"))]
#[allow(dead_code)]
fn items_to_hashmap<T: NameValuePair>(items: &[T]) -> HashMap<String, String> {
items
.iter()
@@ -474,53 +255,64 @@ fn apply_values<T: NameValuePair>(items: &mut [T], values: &HashMap<String, Stri
}
#[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]
// }
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);
}
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>> {
serde_json::from_value::<HashMap<String, JSONMCPServer>>(config)
}
#[cfg(not(target_family = "wasm"))]
pub trait MCPServerExt {
fn from_user_json(json: &str) -> serde_json::Result<Vec<MCPServer>>;
#[cfg(test)]
fn to_user_json(&self) -> String;
fn to_parsed_templatable_mcp_server_result(&self) -> ParsedTemplatableMCPServerResult;
fn fill_environment_variables(&mut self, conn: &mut SqliteConnection);
}
#[cfg(not(target_family = "wasm"))]
impl MCPServerExt for MCPServer {
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("{") {
@@ -531,7 +323,7 @@ impl MCPServer {
let config: serde_json::Value = serde_json::from_str(&json)?;
let servers = Self::find_server_map(config)?;
let servers = find_server_map(config)?;
Ok(servers
.iter()
.map(|(name, server)| {
@@ -565,7 +357,8 @@ impl MCPServer {
/// Includes the environment variable values, should only be shown to users,
/// not sent to our servers.
pub fn to_user_json(&self) -> String {
#[cfg(test)]
fn to_user_json(&self) -> String {
let transport_type = match &self.transport_type {
TransportType::CLIServer(cli_server) => JSONTransportType::CLIServer {
command: cli_server.command.clone(),
@@ -590,7 +383,7 @@ impl MCPServer {
})
}
pub fn to_parsed_templatable_mcp_server_result(&self) -> ParsedTemplatableMCPServerResult {
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);
@@ -634,23 +427,24 @@ impl MCPServer {
name: self.name.clone(),
description: None,
template: JsonTemplate { json, variables },
version: DateTime::now().timestamp(),
version: chrono::Local::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,
variable_values.clone(),
));
ParsedTemplatableMCPServerResult {
templatable_mcp_server,
templatable_mcp_server_installation,
variable_values,
}
}
pub fn fill_environment_variables(&mut self, conn: &mut SqliteConnection) {
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
@@ -670,24 +464,6 @@ impl MCPServer {
}
}
#[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,
@@ -709,5 +485,91 @@ pub enum MCPServerUpdate {
},
}
pub(crate) fn home_config_file_path(provider: MCPProvider) -> Option<PathBuf> {
match provider {
MCPProvider::Warp => galaxy_core::paths::warp_home_mcp_config_file_path(),
_ => dirs::home_dir().map(|home_dir| home_dir.join(provider.home_config_path())),
}
}
#[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 mod_test;
#[path = "mod_tests.rs"]
mod tests;
+987
View File
@@ -0,0 +1,987 @@
use std::collections::HashMap;
use serde_json;
use warp_managed_secrets::ManagedSecretValue;
#[cfg(not(target_family = "wasm"))]
use crate::ai::mcp::parsing::normalize_codex_toml_to_json;
use crate::ai::mcp::parsing::resolve_json;
use crate::ai::mcp::{
mcp_provider_from_file_path, CLIServer, JsonTemplate, MCPProvider, MCPServer, MCPServerExt,
ParsedTemplatableMCPServerResult, ServerSentEvents, StaticEnvVar, StaticHeader,
TemplatableMCPServer, TemplatableMCPServerInstallation, TemplateVariable, TransportType,
VariableType, VariableValue,
};
#[test]
fn mcp_provider_from_file_path_recognizes_warp_home_path() {
if let Some(warp_home_mcp_config_file_path) = galaxy_core::paths::warp_home_mcp_config_file_path()
{
assert_eq!(
mcp_provider_from_file_path(&warp_home_mcp_config_file_path),
Some(MCPProvider::Warp)
);
}
}
/// Helper function to create a test TemplatableMCPServerInstallation with custom values
fn create_test_installation(
name: &str,
template_json: &str,
variables: Vec<(&str, &str)>,
) -> TemplatableMCPServerInstallation {
let template_variables = variables
.iter()
.map(|(key, _)| TemplateVariable {
key: key.to_string(),
allowed_values: None,
})
.collect();
let variable_values = variables
.into_iter()
.map(|(key, value)| {
(
key.to_string(),
VariableValue {
variable_type: VariableType::Text,
value: value.to_string(),
},
)
})
.collect();
let templatable_mcp_server = TemplatableMCPServer {
uuid: uuid::Uuid::new_v4(),
name: name.to_string(),
description: None,
template: JsonTemplate {
json: template_json.to_string(),
variables: template_variables,
},
version: 1234567890,
gallery_data: None,
};
TemplatableMCPServerInstallation::new(
uuid::Uuid::new_v4(),
templatable_mcp_server,
variable_values,
)
}
#[test]
fn test_sse_server_with_headers() {
// Test that ServerSentEvents transport type with headers serializes correctly
let static_headers = vec![
StaticHeader {
name: "Authorization".to_string(),
value: "Bearer token123".to_string(),
},
StaticHeader {
name: "X-Custom-Header".to_string(),
value: "custom-value".to_string(),
},
];
let sse_server = ServerSentEvents {
url: "https://example.com/sse".to_string(),
headers: static_headers,
};
let mcp_server = MCPServer {
transport_type: TransportType::ServerSentEvents(sse_server),
name: "sse-server-with-headers".to_string(),
uuid: uuid::Uuid::new_v4(),
};
// Test to_user_json includes headers
let user_json = mcp_server.to_user_json();
assert!(
user_json.contains("Bearer token123"),
"User JSON should contain Authorization header value: {user_json}",
);
assert!(
user_json.contains("X-Custom-Header"),
"User JSON should contain custom header name: {user_json}",
);
assert!(
user_json.contains("custom-value"),
"User JSON should contain custom header value: {user_json}",
);
// Test from_user_json can parse headers
let parsed_servers =
MCPServer::from_user_json(&user_json).expect("Failed to parse MCP server from user JSON");
assert_eq!(parsed_servers.len(), 1);
let parsed_server = &parsed_servers[0];
if let TransportType::ServerSentEvents(parsed_sse) = &parsed_server.transport_type {
assert_eq!(parsed_sse.url, "https://example.com/sse");
assert_eq!(parsed_sse.headers.len(), 2);
assert!(parsed_sse
.headers
.iter()
.any(|h| h.name == "Authorization" && h.value == "Bearer token123"));
assert!(parsed_sse
.headers
.iter()
.any(|h| h.name == "X-Custom-Header" && h.value == "custom-value"));
} else {
panic!("Expected ServerSentEvents transport type");
}
}
#[test]
fn test_sse_server_headers_default() {
// Test that headers default to empty map when not specified
let json = r#"{
"test-server": {
"url": "https://example.com/sse"
}
}"#;
let servers = MCPServer::from_user_json(json).expect("Failed to parse MCP servers");
assert_eq!(servers.len(), 1);
if let TransportType::ServerSentEvents(sse_server) = &servers[0].transport_type {
assert_eq!(sse_server.url, "https://example.com/sse");
assert!(
sse_server.headers.is_empty(),
"Headers should default to empty"
);
} else {
panic!("Expected ServerSentEvents transport type");
}
}
#[test]
fn test_hash_consistency() {
// Create two identical installations and verify they produce the same hash
let installation1 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","args":["server"],"env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "secret123")],
);
let installation2 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","args":["server"],"env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "secret123")],
);
assert_eq!(
installation1.hash().expect("hash should succeed"),
installation2.hash().expect("hash should succeed"),
"Identical installations should produce the same hash"
);
}
#[test]
fn test_hash_different_names() {
// Verify that different names produce different hashes
let installation1 = create_test_installation(
"server-one",
r#"{"server-one":{"command":"npx","args":["server"],"env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "secret123")],
);
let installation2 = create_test_installation(
"server-two",
r#"{"server-two":{"command":"npx","args":["server"],"env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "secret123")],
);
assert_ne!(
installation1.hash().expect("hash should succeed"),
installation2.hash().expect("hash should succeed"),
"Installations with different names should produce different hashes"
);
}
#[test]
fn test_hash_different_variable_values() {
// Verify that different variable values produce different hashes
let installation1 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","args":["server"],"env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "secret123")],
);
let installation2 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","args":["server"],"env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "different-secret")],
);
assert_ne!(
installation1.hash().expect("hash should succeed"),
installation2.hash().expect("hash should succeed"),
"Installations with different variable values should produce different hashes"
);
}
#[test]
fn test_hash_different_json_templates() {
// Verify that different JSON templates produce different hashes
let installation1 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","args":["server"],"env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "secret123")],
);
let installation2 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"python","args":["server.py"],"env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "secret123")],
);
assert_ne!(
installation1.hash().expect("hash should succeed"),
installation2.hash().expect("hash should succeed"),
"Installations with different JSON templates should produce different hashes"
);
}
#[test]
fn test_hash_different_variables() {
// Verify that different variables produce different hashes
let installation1 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","args":["server"],"env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "secret123")],
);
let installation2 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","args":["server"],"env":{"TOKEN":"{{TOKEN}}"}}}"#,
vec![("TOKEN", "secret123")],
);
assert_ne!(
installation1.hash().expect("hash should succeed"),
installation2.hash().expect("hash should succeed"),
"Installations with different variables should produce different hashes"
);
}
#[test]
fn test_hash_multiple_variables_order_independent() {
// Verify that variable order doesn't affect hash (BTreeMap ensures consistent ordering)
let installation1 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","env":{"API_KEY":"{{API_KEY}}","TOKEN":"{{TOKEN}}"}}}"#,
vec![("API_KEY", "secret123"), ("TOKEN", "token456")],
);
let installation2 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","env":{"API_KEY":"{{API_KEY}}","TOKEN":"{{TOKEN}}"}}}"#,
vec![("TOKEN", "token456"), ("API_KEY", "secret123")],
);
assert_eq!(
installation1.hash().expect("hash should succeed"),
installation2.hash().expect("hash should succeed"),
"Hash should be order-independent for multiple variables"
);
}
#[test]
fn test_hash_ignores_installation_uuid() {
// Verify that installation UUID doesn't affect hash (only name, template, and variable values)
let installation1 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","args":["server"],"env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "secret123")],
);
let installation2 = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","args":["server"],"env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "secret123")],
);
// Even though these have different UUIDs (created separately), hashes should be the same
assert_ne!(
installation1.uuid(),
installation2.uuid(),
"UUIDs should be different"
);
assert_eq!(
installation1.hash().expect("hash should succeed"),
installation2.hash().expect("hash should succeed"),
"Hash should not depend on installation UUID"
);
}
#[test]
fn test_to_parsed_templatable_mcp_server_result() {
let mcp_server = MCPServer {
transport_type: TransportType::CLIServer(CLIServer {
command: "npx".to_string(),
args: vec!["@modelcontextprotocol/server-postgres".to_string()],
cwd_parameter: None,
static_env_vars: vec![StaticEnvVar {
name: "API_KEY".to_string(),
value: "SOME_SECRET".to_string(),
}],
}),
name: "test-server".to_string(),
uuid: uuid::Uuid::new_v4(),
};
let parsed_result = mcp_server.to_parsed_templatable_mcp_server_result();
let actual_json_value = serde_json::from_str::<serde_json::Value>(
parsed_result.templatable_mcp_server.template.json.as_str(),
)
.unwrap();
let expected_json_value = serde_json::from_str::<serde_json::Value>(r#"{"test-server":{"command":"npx","args":["@modelcontextprotocol/server-postgres"],"env":{"API_KEY":"{{API_KEY}}"},"working_directory":null}}"#).unwrap();
assert_eq!(parsed_result.templatable_mcp_server.name, "test-server");
assert_eq!(actual_json_value, expected_json_value);
assert_eq!(
parsed_result
.templatable_mcp_server
.template
.variables
.len(),
1
);
assert_eq!(
parsed_result.templatable_mcp_server.template.variables[0].key,
"API_KEY"
);
let variable_values = parsed_result
.templatable_mcp_server_installation
.as_ref()
.unwrap()
.variable_values();
assert_eq!(variable_values.len(), 1);
assert_eq!(variable_values["API_KEY"].variable_type, VariableType::Text);
assert_eq!(variable_values["API_KEY"].value, "SOME_SECRET");
}
#[test]
fn test_to_parsed_templatable_mcp_server_result_sse_headers() {
let mcp_server = MCPServer {
transport_type: TransportType::ServerSentEvents(ServerSentEvents {
url: "https://example.com/sse".to_string(),
headers: vec![
StaticHeader {
name: "Authorization".to_string(),
value: "Bearer token123".to_string(),
},
StaticHeader {
name: "X-Custom-Header".to_string(),
value: "custom-value".to_string(),
},
],
}),
name: "sse-server".to_string(),
uuid: uuid::Uuid::new_v4(),
};
let parsed_result = mcp_server.to_parsed_templatable_mcp_server_result();
let actual_json_value = serde_json::from_str::<serde_json::Value>(
parsed_result.templatable_mcp_server.template.json.as_str(),
)
.unwrap();
let expected_json_value = serde_json::from_str::<serde_json::Value>(
r#"{"sse-server":{"url":"https://example.com/sse","headers":{"Authorization":"{{Authorization}}","X-Custom-Header":"{{X-Custom-Header}}"}}}"#,
)
.unwrap();
assert_eq!(actual_json_value, expected_json_value);
let mut variable_keys = parsed_result
.templatable_mcp_server
.template
.variables
.iter()
.map(|v| v.key.as_str())
.collect::<Vec<_>>();
variable_keys.sort();
assert_eq!(variable_keys, vec!["Authorization", "X-Custom-Header"]);
let variable_values = parsed_result
.templatable_mcp_server_installation
.as_ref()
.unwrap()
.variable_values();
assert_eq!(variable_values["Authorization"].value, "Bearer token123");
assert_eq!(
variable_values["Authorization"].variable_type,
VariableType::Text
);
assert_eq!(variable_values["X-Custom-Header"].value, "custom-value");
assert_eq!(
variable_values["X-Custom-Header"].variable_type,
VariableType::Text
);
}
#[test]
fn test_parse_cli_server_without_args() {
// MCP configs should work without an explicit "args" field.
// The args field should default to an empty array.
let json = r#"{
"my-server": {
"command": "uvx",
"env": {
"API_KEY": "secret123"
}
}
}"#;
let servers = MCPServer::from_user_json(json).expect("Failed to parse MCP servers");
assert_eq!(servers.len(), 1);
if let TransportType::CLIServer(cli_server) = &servers[0].transport_type {
assert_eq!(cli_server.command, "uvx");
assert!(
cli_server.args.is_empty(),
"Args should default to empty when not specified"
);
} else {
panic!("Expected CLIServer transport type");
}
}
#[test]
fn test_parse_cli_server_preserves_explicit_working_directory() {
// An explicitly-set `working_directory` in a `.mcp.json`-style config must
// round-trip into `CLIServer.cwd_parameter` so the file-based spawner does
// not overwrite it with the discovery-root default.
let json = r#"{
"my-server": {
"command": "node",
"args": ["./tooling/mcp/server.js"],
"working_directory": "/explicit/override/path"
}
}"#;
let servers = MCPServer::from_user_json(json).expect("Failed to parse MCP servers");
assert_eq!(servers.len(), 1);
let TransportType::CLIServer(cli_server) = &servers[0].transport_type else {
panic!("Expected CLIServer transport type");
};
assert_eq!(
cli_server.cwd_parameter.as_deref(),
Some("/explicit/override/path"),
"Explicit working_directory must be preserved through parsing"
);
}
#[test]
fn test_parse_templatable_cli_server_without_args_and_resolve_json() {
// Templatable MCP configs should work without an explicit "args" field.
let json = r#"{
"my-server": {
"command": "uvx",
"env": {
"API_KEY": "secret123"
}
}
}"#;
let parsed = ParsedTemplatableMCPServerResult::from_user_json(json)
.expect("Failed to parse templatable MCP server JSON");
assert_eq!(parsed.len(), 1);
let installation = parsed[0]
.templatable_mcp_server_installation
.as_ref()
.expect("Installation should be present when all variables are provided");
// The resolved JSON should parse successfully via MCPServer::from_user_json
let resolved = resolve_json(installation);
let servers = MCPServer::from_user_json(&resolved).expect("Failed to parse resolved MCP JSON");
assert_eq!(servers.len(), 1);
if let TransportType::CLIServer(cli_server) = &servers[0].transport_type {
assert_eq!(cli_server.command, "uvx");
assert!(
cli_server.args.is_empty(),
"Args should default to empty when not specified"
);
} else {
panic!("Expected CLIServer transport type");
}
}
// ── Codex TOML normalizer tests ────────────────────────────────────────
/// Basic STDIO server: `command` + `args` round-trips cleanly.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_basic_stdio_server() {
let toml = r#"
[mcp_servers.context7]
command = "npx"
args = ["-y", "@upstash/context7-mcp"]
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let parsed = ParsedTemplatableMCPServerResult::from_user_json(&json)
.expect("from_user_json should succeed");
assert_eq!(parsed.len(), 1);
let server = &parsed[0].templatable_mcp_server;
assert_eq!(server.name, "context7");
// No env vars → installation should still be present (no missing variables)
assert!(
parsed[0].templatable_mcp_server_installation.is_some(),
"installation should be present when there are no template variables"
);
}
/// `env_vars` entries are lowered to `${NAME}` placeholders in the env map.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_env_vars_become_placeholders() {
let toml = r#"
[mcp_servers.my_stdio]
command = "npx"
args = ["-y", "@example/mcp-server"]
env_vars = ["MY_API_KEY"]
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let value: serde_json::Value =
serde_json::from_str(&json).expect("normalized output should be valid JSON");
let env = &value["mcp_servers"]["my_stdio"]["env"];
assert_eq!(
env["MY_API_KEY"].as_str(),
Some("${MY_API_KEY}"),
"env_vars entry should become a ${{NAME}} placeholder"
);
}
/// Explicit `env` values win over `env_vars` placeholders on collision.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_explicit_env_wins_over_env_vars_on_collision() {
let toml = r#"
[mcp_servers.my_stdio]
command = "npx"
args = ["-y", "@example/mcp-server"]
env_vars = ["MY_API_KEY"]
[mcp_servers.my_stdio.env]
MY_API_KEY = "literal-value"
LOG_LEVEL = "info"
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let value: serde_json::Value =
serde_json::from_str(&json).expect("normalized output should be valid JSON");
let env = &value["mcp_servers"]["my_stdio"]["env"];
// Explicit env wins: literal value, not the placeholder
assert_eq!(
env["MY_API_KEY"].as_str(),
Some("literal-value"),
"explicit env entry should override env_vars placeholder"
);
assert_eq!(
env["LOG_LEVEL"].as_str(),
Some("info"),
"non-colliding explicit env entry should be present"
);
}
/// `cwd` is mapped to `working_directory` in the output JSON.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_cwd_maps_to_working_directory() {
let toml = r#"
[mcp_servers.my_stdio]
command = "npx"
args = ["-y", "@example/mcp-server"]
cwd = "/home/user/project"
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let value: serde_json::Value =
serde_json::from_str(&json).expect("normalized output should be valid JSON");
assert_eq!(
value["mcp_servers"]["my_stdio"]["working_directory"].as_str(),
Some("/home/user/project"),
"cwd should be mapped to working_directory"
);
}
/// A TOML with one STDIO and one HTTP server produces both in the output.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_mixed_stdio_and_http_servers() {
let toml = r#"
[mcp_servers.my_stdio]
command = "npx"
args = ["-y", "@example/mcp-server"]
[mcp_servers.my_http]
url = "https://example.com/mcp"
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let value: serde_json::Value =
serde_json::from_str(&json).expect("normalized output should be valid JSON");
let servers = value["mcp_servers"]
.as_object()
.expect("mcp_servers should be an object");
assert!(
servers.contains_key("my_stdio"),
"STDIO server should be present"
);
assert!(
servers.contains_key("my_http"),
"HTTP server should be present"
);
}
/// An HTTP server with only a `url` field round-trips correctly.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_http_url_only() {
let toml = r#"
[mcp_servers.my_http]
url = "https://example.com/mcp"
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let value: serde_json::Value =
serde_json::from_str(&json).expect("normalized output should be valid JSON");
let server = &value["mcp_servers"]["my_http"];
assert_eq!(
server["url"].as_str(),
Some("https://example.com/mcp"),
"url should be present in output"
);
}
/// `bearer_token_env_var` is lowered to `Authorization: "Bearer ${VAR}"`.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_http_bearer_token_env_var() {
let toml = r#"
[mcp_servers.my_http]
url = "https://example.com/mcp"
bearer_token_env_var = "MCP_TOKEN"
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let value: serde_json::Value =
serde_json::from_str(&json).expect("normalized output should be valid JSON");
assert_eq!(
value["mcp_servers"]["my_http"]["headers"]["Authorization"].as_str(),
Some("Bearer ${MCP_TOKEN}"),
"bearer_token_env_var should produce Authorization: Bearer ${{VAR}}"
);
}
/// `env_http_headers` entries become `header: "${VAR}"` placeholders.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_http_env_http_headers() {
let toml = r#"
[mcp_servers.my_http]
url = "https://example.com/mcp"
env_http_headers = { "X-Api-Key" = "MCP_API_KEY" }
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let value: serde_json::Value =
serde_json::from_str(&json).expect("normalized output should be valid JSON");
assert_eq!(
value["mcp_servers"]["my_http"]["headers"]["X-Api-Key"].as_str(),
Some("${MCP_API_KEY}"),
"env_http_headers entry should become a ${{VAR}} placeholder"
);
}
/// `http_headers` static values are passed through verbatim.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_http_static_headers() {
let toml = r#"
[mcp_servers.my_http]
url = "https://example.com/mcp"
http_headers = { "X-Client" = "codex" }
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let value: serde_json::Value =
serde_json::from_str(&json).expect("normalized output should be valid JSON");
assert_eq!(
value["mcp_servers"]["my_http"]["headers"]["X-Client"].as_str(),
Some("codex"),
"http_headers static value should pass through verbatim"
);
}
/// `http_headers` wins over `env_http_headers` on collision.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_http_static_headers_win_over_env_headers_on_collision() {
let toml = r#"
[mcp_servers.my_http]
url = "https://example.com/mcp"
env_http_headers = { "X-Api-Key" = "MCP_API_KEY" }
http_headers = { "X-Api-Key" = "static-override" }
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let value: serde_json::Value =
serde_json::from_str(&json).expect("normalized output should be valid JSON");
assert_eq!(
value["mcp_servers"]["my_http"]["headers"]["X-Api-Key"].as_str(),
Some("static-override"),
"http_headers should override env_http_headers on collision"
);
}
/// Entries with neither `command` nor `url` are skipped.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_unknown_entry_skipped() {
let toml = r#"
[mcp_servers.my_stdio]
command = "npx"
[mcp_servers.mystery]
some_unknown_field = "value"
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let value: serde_json::Value =
serde_json::from_str(&json).expect("normalized output should be valid JSON");
let servers = value["mcp_servers"]
.as_object()
.expect("mcp_servers should be an object");
assert!(
servers.contains_key("my_stdio"),
"STDIO server should be present"
);
assert!(
!servers.contains_key("mystery"),
"entry with neither command nor url should be skipped"
);
}
/// Full round-trip: TOML with env + env_vars parses into a working installation
/// whose resolved JSON is consumable by `MCPServer::from_user_json`.
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_codex_toml_round_trip_through_from_user_json() {
let toml = r#"
[mcp_servers.my_stdio]
command = "npx"
args = ["-y", "@example/mcp-server"]
[mcp_servers.my_stdio.env]
LOG_LEVEL = "info"
"#;
let json = normalize_codex_toml_to_json(toml).expect("normalization should succeed");
let parsed = ParsedTemplatableMCPServerResult::from_user_json(&json)
.expect("from_user_json should succeed");
assert_eq!(parsed.len(), 1);
let installation = parsed[0]
.templatable_mcp_server_installation
.as_ref()
.expect("installation should be present when all variables have values");
// The installation's variable values should contain LOG_LEVEL
let variable_values = installation.variable_values();
assert_eq!(
variable_values["LOG_LEVEL"].value, "info",
"explicit env value should be stored in installation"
);
// Resolved JSON should parse as a valid MCPServer
let resolved = resolve_json(installation);
let servers =
MCPServer::from_user_json(&resolved).expect("resolved JSON should parse as MCPServer");
assert_eq!(servers.len(), 1);
if let TransportType::CLIServer(cli) = &servers[0].transport_type {
assert_eq!(cli.command, "npx");
assert_eq!(cli.args, vec!["-y", "@example/mcp-server"]);
} else {
panic!("Expected CLIServer transport type");
}
}
#[test]
fn test_parse_templatable_sse_headers_and_resolve_json() {
let json = r#"{
"sse-server": {
"url": "https://example.com/sse",
"headers": {
"Authorization": "Bearer token123",
"X-Custom-Header": "custom-value"
}
}
}"#;
let parsed = ParsedTemplatableMCPServerResult::from_user_json(json)
.expect("Failed to parse templatable MCP server JSON");
assert_eq!(parsed.len(), 1);
let templatable = &parsed[0].templatable_mcp_server;
let installation = parsed[0]
.templatable_mcp_server_installation
.as_ref()
.expect("Installation should be present when all variables are provided");
let template_value =
serde_json::from_str::<serde_json::Value>(templatable.template.json.as_str()).unwrap();
let expected_template_value = serde_json::from_str::<serde_json::Value>(
r#"{"sse-server":{"url":"https://example.com/sse","headers":{"Authorization":"{{Authorization}}","X-Custom-Header":"{{X-Custom-Header}}"}}}"#,
)
.unwrap();
assert_eq!(template_value, expected_template_value);
let variable_values = installation.variable_values();
assert_eq!(variable_values["Authorization"].value, "Bearer token123");
assert_eq!(variable_values["X-Custom-Header"].value, "custom-value");
let resolved_value =
serde_json::from_str::<serde_json::Value>(&resolve_json(installation)).unwrap();
let expected_resolved_value = serde_json::from_str::<serde_json::Value>(
r#"{"sse-server":{"url":"https://example.com/sse","headers":{"Authorization":"Bearer token123","X-Custom-Header":"custom-value"}}}"#,
)
.unwrap();
assert_eq!(resolved_value, expected_resolved_value);
}
// --- Runtime handlebars secret resolution tests ---
fn make_secrets(pairs: Vec<(&str, &str)>) -> HashMap<String, ManagedSecretValue> {
pairs
.into_iter()
.map(|(k, v)| {
(
k.to_string(),
ManagedSecretValue::RawValue {
value: v.to_string(),
},
)
})
.collect()
}
#[test]
fn test_apply_secrets_resolves_explicit_handlebars_in_env_value() {
// Parser templatizes "API_KEY": "{{secret_one}}" → template has {{API_KEY}},
// variable value is API_KEY = "{{secret_one}}". apply_secrets renders
// the explicit {{...}} ref against the secrets map.
let mut installation = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "{{secret_one}}")],
);
let secrets = make_secrets(vec![("secret_one", "real_api_key_value")]);
installation.apply_secrets(&secrets);
assert_eq!(
installation.variable_values()["API_KEY"].value,
"real_api_key_value"
);
}
#[test]
fn test_apply_secrets_resolves_bearer_header_with_handlebars() {
// "Authorization": "Bearer {{my_token}}" → variable value is
// Authorization = "Bearer {{my_token}}". apply_secrets renders the
// embedded ref while keeping the Bearer prefix.
let mut installation = create_test_installation(
"sse-server",
r#"{"sse-server":{"url":"https://example.com","headers":{"Authorization":"{{Authorization}}"}}}"#,
vec![("Authorization", "Bearer {{my_token}}")],
);
let secrets = make_secrets(vec![("my_token", "tok_abc123")]);
installation.apply_secrets(&secrets);
assert_eq!(
installation.variable_values()["Authorization"].value,
"Bearer tok_abc123"
);
}
#[test]
fn test_apply_secrets_skips_plain_values() {
// Plain values like "info" contain no {{...}} and should be left unchanged
// when no secret matches the key name.
let mut installation = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","env":{"LOG_LEVEL":"{{LOG_LEVEL}}"}}}"#,
vec![("LOG_LEVEL", "info")],
);
let secrets = make_secrets(vec![("some_secret", "value")]);
installation.apply_secrets(&secrets);
assert_eq!(installation.variable_values()["LOG_LEVEL"].value, "info");
}
#[test]
fn test_apply_secrets_explicit_refs_take_priority_over_key_match() {
// If a value contains {{secret_one}} and a secret named API_KEY also exists,
// the explicit {{secret_one}} should win.
let mut installation = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "{{secret_one}}")],
);
let secrets = make_secrets(vec![
("secret_one", "correct_value"),
("API_KEY", "wrong_value_from_key_match"),
]);
installation.apply_secrets(&secrets);
assert_eq!(
installation.variable_values()["API_KEY"].value,
"correct_value"
);
}
#[test]
fn test_apply_secrets_mixed_explicit_and_implicit() {
// Mixed case: one variable uses explicit {{...}} ref, another uses
// implicit key-name matching.
let mut installation = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","env":{"API_KEY":"{{API_KEY}}","LOG_LEVEL":"{{LOG_LEVEL}}"}}}"#,
vec![("API_KEY", "{{secret_one}}"), ("LOG_LEVEL", "info")],
);
let secrets = make_secrets(vec![
("secret_one", "resolved_secret"),
("LOG_LEVEL", "debug_from_secret"),
]);
installation.apply_secrets(&secrets);
// API_KEY resolved via explicit handlebars
assert_eq!(
installation.variable_values()["API_KEY"].value,
"resolved_secret"
);
// LOG_LEVEL resolved via implicit key-name matching
assert_eq!(
installation.variable_values()["LOG_LEVEL"].value,
"debug_from_secret"
);
}
#[test]
fn test_apply_secrets_missing_secret_leaves_placeholder() {
// If the referenced secret doesn't exist, the {{...}} placeholder
// should remain in the value.
let mut installation = create_test_installation(
"test-server",
r#"{"test-server":{"command":"npx","env":{"API_KEY":"{{API_KEY}}"}}}"#,
vec![("API_KEY", "{{nonexistent_secret}}")],
);
let secrets = make_secrets(vec![]);
installation.apply_secrets(&secrets);
assert_eq!(
installation.variable_values()["API_KEY"].value,
"{{nonexistent_secret}}"
);
}
+27 -31
View File
@@ -1,22 +1,16 @@
use std::collections::HashMap;
use chrono::DateTime;
use handlebars::{get_arguments, render_template};
#[cfg(feature = "local_fs")]
use serde::Deserialize;
use crate::ai::mcp::templatable::{JsonTemplate, TemplatableMCPServer, TemplateVariable};
use crate::ai::mcp::templatable_installation::{
TemplatableMCPServerInstallation, VariableType, VariableValue,
};
#[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),
@@ -196,6 +190,8 @@ pub(crate) fn normalize_codex_toml_to_json(file_contents: &str) -> Result<String
pub struct ParsedTemplatableMCPServerResult {
pub templatable_mcp_server: TemplatableMCPServer,
pub templatable_mcp_server_installation: Option<TemplatableMCPServerInstallation>,
#[cfg_attr(target_family = "wasm", expect(dead_code))]
pub variable_values: HashMap<String, VariableValue>,
}
/// Extracts a field from JSON as a HashMap<String, String>.
@@ -241,6 +237,7 @@ impl ParsedTemplatableMCPServerResult {
/// 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.
#[cfg(not(target_family = "wasm"))]
pub fn from_config_file_json(json: &str) -> serde_json::Result<Vec<Self>> {
let json = json.trim();
let json = if json.starts_with('{') {
@@ -329,7 +326,7 @@ impl ParsedTemplatableMCPServerResult {
json: normalized_json,
variables,
},
version: DateTime::now().timestamp(),
version: chrono::Local::now().timestamp(),
gallery_data: None,
};
@@ -348,33 +345,32 @@ impl ParsedTemplatableMCPServerResult {
.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();
let variable_values: HashMap<String, VariableValue> = 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,
))
}
let templatable_mcp_server_installation = match all_variables_present {
true => Some(TemplatableMCPServerInstallation::new(
uuid::Uuid::new_v4(),
templatable_mcp_server.clone(),
variable_values.clone(),
)),
false => None,
};
ParsedTemplatableMCPServerResult {
templatable_mcp_server,
templatable_mcp_server_installation,
variable_values,
}
}
}
+2 -2
View File
@@ -120,7 +120,7 @@ impl ReconnectingPeer {
/// Calls a tool on the MCP server.
pub async fn call_tool(
&self,
params: rmcp::model::CallToolRequestParam,
params: rmcp::model::CallToolRequestParams,
) -> Result<rmcp::model::CallToolResult, rmcp::ServiceError> {
self.with_reconnect_retry(params, |peer, p| async move { peer.call_tool(p).await })
.await
@@ -129,7 +129,7 @@ impl ReconnectingPeer {
/// Reads a resource from the MCP server.
pub async fn read_resource(
&self,
params: rmcp::model::ReadResourceRequestParam,
params: rmcp::model::ReadResourceRequestParams,
) -> Result<rmcp::model::ReadResourceResult, rmcp::ServiceError> {
self.with_reconnect_retry(params, |peer, p| async move { peer.read_resource(p).await })
.await
+17 -235
View File
@@ -1,233 +1,24 @@
use std::collections::HashMap;
use chrono::DateTime;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{AppContext, SingletonEntity as _};
use handlebars::get_arguments;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
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},
pub use cloud_object_models::{
CloudTemplatableMCPServer, CloudTemplatableMCPServerModel, GalleryData, JsonTemplate,
TemplatableMCPServer, TemplateVariable,
};
use galaxy_core::ui::appearance::Appearance;
use crate::cloud_object::model::generic_string_model::StringModel;
use crate::cloud_object::model::json_model::JsonModel;
use crate::cloud_object::{
CloudObjectUuid, GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType,
Revision, UniquePer,
};
use crate::drive::items::WarpDriveItem;
use crate::server::ids::SyncId;
use crate::server::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 CloudObjectUuid for TemplatableMCPServer {
fn uuid(&self) -> uuid::Uuid {
self.uuid
}
}
@@ -270,15 +61,6 @@ impl StringModel for TemplatableMCPServer {
}
}
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),
+3 -4
View File
@@ -1,15 +1,14 @@
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::hash::{Hash, Hasher};
use galaxy_managed_secrets::ManagedSecretValue;
use handlebars::{get_arguments, render_template};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::hash::{Hash, Hasher};
use siphasher::sip::SipHasher;
use uuid::Uuid;
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);
+22 -78
View File
@@ -1,29 +1,31 @@
#[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 diesel::SqliteConnection;
use futures_util::stream::AbortHandle;
#[cfg(not(target_family = "wasm"))]
use mcp::oauth;
use mcp::TemplatableMCPServerInfo;
#[cfg(not(target_family = "wasm"))]
pub use native::McpIntegration;
#[cfg(not(target_family = "wasm"))]
use parking_lot::Mutex;
use uuid::Uuid;
#[cfg(not(target_family = "wasm"))]
use galaxyui::ModelSpawner;
use galaxyui::{Entity, SingletonEntity};
use uuid::Uuid;
#[cfg(not(target_family = "wasm"))]
use crate::ai::mcp::templatable::CloudTemplatableMCPServer;
use crate::ai::mcp::templatable_installation::TemplatableMCPServerInstallation;
use crate::ai::mcp::{FileBasedMCPManager, MCPServerState};
#[cfg(not(target_family = "wasm"))]
type ReconnectResultSender =
@@ -92,48 +94,6 @@ struct SpawnedServerInfo {
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 {
@@ -216,13 +176,13 @@ impl TemplatableMCPServerManager {
pub fn resources(&self) -> impl Iterator<Item = &rmcp::model::Resource> {
self.active_servers
.values()
.flat_map(|server| server.resources.iter())
.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())
.flat_map(|server| server.tools().iter())
}
/// Returns a reconnecting peer for a server that has the given resource.
@@ -236,12 +196,7 @@ impl TemplatableMCPServerManager {
let spawner = self.spawner.as_ref()?;
self.active_servers
.iter()
.find(|(_, server)| {
server
.resources
.iter()
.any(|other_resource| resource.uri == other_resource.uri)
})
.find(|(_, server)| server.has_resource(resource))
.map(|(installation_uuid, _)| {
super::reconnecting_peer::ReconnectingPeer::new(*installation_uuid, spawner.clone())
})
@@ -250,7 +205,7 @@ impl TemplatableMCPServerManager {
pub fn tools_for_server(&self, uuid: Uuid) -> Vec<rmcp::model::Tool> {
self.active_servers
.get(&uuid)
.map(|server| server.tools.clone())
.map(|server| server.tools().clone())
.unwrap_or_default()
}
@@ -268,24 +223,21 @@ impl TemplatableMCPServerManager {
installation_id: Option<Uuid>,
tool_name: &str,
) -> Option<std::sync::Arc<rmcp::model::JsonObject>> {
let candidates: Box<dyn Iterator<Item = &TemplatableMCPServerInfo>> =
let mut 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())
candidates.find_map(|server| server.tool_input_schema(tool_name))
}
#[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))
.find(|(_, server)| server.has_tool(&tool))
.map(|(uuid, _)| uuid)
}
@@ -295,15 +247,7 @@ impl TemplatableMCPServerManager {
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
}
})
})
.find(|(_, server)| server.has_resource_name_or_uri(name, uri))
.map(|(uuid, _)| uuid)
}
+258 -578
View File
@@ -1,72 +1,59 @@
use crate::ai::mcp::file_based_manager::FileBasedMCPManagerEvent;
use crate::ai::mcp::templatable_manager::oauth::{
load_credentials_from_secure_storage, write_to_secure_storage, FILE_BASED_MCP_CREDENTIALS_KEY,
TEMPLATABLE_MCP_CREDENTIALS_KEY,
};
use crate::ai::mcp::FileBasedMCPManager;
use core::fmt;
use itertools::Itertools;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::{collections::HashMap, future::Future};
use crate::ai::mcp::http_client::build_client_with_headers;
use crate::ai::mcp::templatable::GalleryData;
use crate::ai::mcp::templatable_manager::FigmaMcpStatus;
use crate::ai::mcp::{
Author, CloudMCPServer, JsonTemplate, MCPGalleryManager, MCPServerUpdate,
ParsedTemplatableMCPServerResult,
};
use crate::ai::mcp::parsing::resolve_json;
use crate::ai::mcp::TemplatableMCPServer;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::{CloudObject, CloudObjectLocation, CloudObjectMetadataExt, Space};
use crate::server::cloud_objects::update_manager::InitiatedBy;
use crate::server::ids::{ClientId, ServerId};
use crate::server::telemetry::{
MCPServerModel, MCPServerTelemetryTransportType, MCPTemplateCreationSource,
};
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{
ai::mcp::{
logs, templatable::CloudTemplatableMCPServer, templatable_installation::VariableValue,
MCPServer, StaticEnvVar, TemplatableMCPServerInstallation, TransportType,
},
cloud_object::{GenericStringObjectFormat, JsonObjectType},
drive::CloudObjectTypeAndId,
persistence::ModelEvent,
send_telemetry_from_ctx,
server::{
cloud_objects::update_manager::UpdateManager, ids::SyncId, telemetry::TelemetryEvent,
},
settings::AISettings,
view_components::DismissibleToast,
workspace::ToastStack,
GlobalResourceHandlesProvider,
};
use async_compat::CompatExt as _;
use cfg_if::cfg_if;
use futures::FutureExt as _;
use galaxy_core::safe_error;
use galaxy_core::{
execution_mode::AppExecutionMode, features::FeatureFlag, settings::Setting as _,
use mcp::oauth::{
self, load_credentials_from_secure_storage, write_to_secure_storage, AuthContext,
CallbackResult, FileBasedPersistedCredentialsMap, PersistedCredentials,
PersistedCredentialsMap, FILE_BASED_MCP_CREDENTIALS_KEY, TEMPLATABLE_MCP_CREDENTIALS_KEY,
};
use galaxyui::AppContext;
use galaxyui::{windowing::WindowManager, ModelContext, SingletonEntity};
use mcp::runtime::{error_to_user_message, spawn_server};
use parking_lot::Mutex;
use rmcp::{transport::ConfigureCommandExt as _, ServiceExt as _};
use simple_logger::manager::LogManager;
use simple_logger::SimpleLogger;
use tokio::io::AsyncBufReadExt as _;
use url::Url;
use uuid::Uuid;
use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_core::features::FeatureFlag;
use galaxy_core::safe_error;
use galaxy_core::settings::Setting as _;
use galaxyui::windowing::WindowManager;
use galaxyui::{AppContext, ModelContext, SingletonEntity};
use super::{
oauth::{self, AuthContext, FileBasedPersistedCredentialsMap, PersistedCredentialsMap},
MCPServerState, SpawnedServerInfo, TemplatableMCPServerInfo, TemplatableMCPServerManager,
TemplatableMCPServerManagerEvent,
};
use crate::ai::mcp::file_based_manager::FileBasedMCPManagerEvent;
use crate::ai::mcp::parsing::resolve_json;
use crate::ai::mcp::templatable::{CloudTemplatableMCPServer, GalleryData};
use crate::ai::mcp::templatable_installation::VariableValue;
use crate::ai::mcp::templatable_manager::FigmaMcpStatus;
use crate::ai::mcp::{
logs, Author, CloudMCPServer, FileBasedMCPManager, JsonTemplate, MCPGalleryManager, MCPServer,
MCPServerExt, MCPServerUpdate, ParsedTemplatableMCPServerResult, StaticEnvVar,
TemplatableMCPServer, TemplatableMCPServerInstallation, TransportType,
};
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::{
CloudObject, CloudObjectLocation, CloudObjectLookup as _, CloudObjectMetadataExt,
CloudObjectUuidLookup as _, GenericStringObjectFormat, JsonObjectType, Space,
};
use crate::drive::CloudObjectTypeAndId;
use crate::persistence::{
database_file_path_for_scope, establish_ro_connection, ModelEvent, PersistenceScope,
};
use crate::server::cloud_objects::update_manager::{InitiatedBy, UpdateManager};
use crate::server::ids::{ClientId, ServerId, SyncId};
use crate::server::telemetry::{
MCPServerModel, MCPServerTelemetryTransportType, MCPTemplateCreationSource, TelemetryEvent,
};
use crate::settings::AISettings;
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{send_telemetry_from_ctx, GlobalResourceHandlesProvider};
/// Controls the behavior of `spawn_server_impl`.
enum SpawnMode {
@@ -119,49 +106,6 @@ impl fmt::Display for LegacyToTemplatableMCPConversionError {
}
}
/// Convert an rmcp error to a user-friendly error message.
fn error_to_user_message(error: &rmcp::RmcpError) -> String {
match error {
rmcp::RmcpError::ClientInitialize(err) => {
format!("Failed to initialize client: {}", err)
}
rmcp::RmcpError::ServerInitialize(err) => {
format!("Failed to initialize server: {}", err)
}
rmcp::RmcpError::TransportCreation { error, .. } => {
format!("Failed to establish connection: {}", error)
}
rmcp::RmcpError::Runtime(err) => {
format!("Runtime error: {}", err)
}
rmcp::RmcpError::Service(err) => match err {
rmcp::ServiceError::McpError(_) => {
"Server returned an error. Please check server logs for details.".to_string()
}
rmcp::ServiceError::TransportSend(_) => {
"Failed to send data to server. Connection may have been lost.".to_string()
}
rmcp::ServiceError::TransportClosed => {
"Connection closed unexpectedly. The server may have crashed.".to_string()
}
rmcp::ServiceError::UnexpectedResponse => {
"Server sent an unexpected response. The server may be incompatible.".to_string()
}
rmcp::ServiceError::Cancelled { reason } => format!(
"Operation was cancelled with reason: {}",
reason.clone().unwrap_or("Unknown reason".to_string())
),
rmcp::ServiceError::Timeout { timeout } => {
format!(
"Connection timed out after {} seconds. The server may be unresponsive.",
timeout.as_secs()
)
}
_ => format!("Service error: {}", err),
},
}
}
/// An MCP server integration that Warp ships with bundled skills for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpIntegration {
@@ -176,6 +120,106 @@ 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" {
anyhow::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 {
anyhow::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 {
anyhow::bail!("No active OAuth flow found for state={state}");
};
let Some(server_info) = self.spawned_servers.get(&server_uuid) else {
anyhow::bail!("No spawned server found for uuid={server_uuid}");
};
warpui::r#async::block_on(server_info.oauth_result_tx.send(result)).map_err(|_| {
anyhow::anyhow!(
"Failed to send OAuth result to server {server_uuid} - receiver dropped"
)
})?;
self.pending_oauth_csrf.remove(state.as_ref() as &str);
Ok(())
}
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}");
}
}
/// Creates a new [`TemplatableMCPServerManager`] instance.
pub fn new(
locally_installed_servers: HashMap<Uuid, TemplatableMCPServerInstallation>,
@@ -185,7 +229,7 @@ impl TemplatableMCPServerManager {
) -> Self {
// Subscribe to FileBasedMCPManager events.
let file_based_mcp_manager = FileBasedMCPManager::handle(ctx);
ctx.subscribe_to_model(&file_based_mcp_manager, |me, event, ctx| match event {
ctx.subscribe_to_model(&file_based_mcp_manager, |me, _, event, ctx| match event {
FileBasedMCPManagerEvent::SpawnServers { installations } => {
me.spawn_file_based_servers(installations, ctx);
}
@@ -203,7 +247,7 @@ impl TemplatableMCPServerManager {
// TemplatableMCPServerManager is the source of truth for templatable MCP servers stored on the cloud
let cloud_model = CloudModel::handle(ctx);
ctx.subscribe_to_model(&cloud_model, |me, event, ctx| match event {
ctx.subscribe_to_model(&cloud_model, |me, _, event, ctx| match event {
CloudModelEvent::ObjectUpdated {
type_and_id:
CloudObjectTypeAndId::GenericStringObject {
@@ -286,14 +330,13 @@ impl TemplatableMCPServerManager {
_ => {}
});
let database_connection =
crate::persistence::database_file_path()
.to_str()
.and_then(|db_url| {
crate::persistence::establish_ro_connection(db_url)
.ok()
.map(|conn| Arc::new(Mutex::new(conn)))
});
let database_connection = database_file_path_for_scope(&PersistenceScope::App)
.to_str()
.and_then(|db_url| {
establish_ro_connection(db_url)
.ok()
.map(|conn| Arc::new(Mutex::new(conn)))
});
let mut me = Self {
cloud_templatable_mcp_servers: Default::default(),
@@ -803,10 +846,11 @@ impl TemplatableMCPServerManager {
let executor = ctx.background_executor().clone();
let logger = match LogManager::handle(ctx).update(ctx, |mgr, _| {
mgr.register_namespace("mcp", true);
mgr.register(
mgr.register_with_rotation(
"mcp",
logs::relative_log_file_path_from_uuid(&template_uuid),
executor,
logs::mcp_log_rotation_config(),
)
}) {
Ok(logger) => logger,
@@ -838,17 +882,87 @@ impl TemplatableMCPServerManager {
.get_hash_by_uuid(installation_uuid)
.is_some();
let auth_context = AuthContext {
oauth_result_rx,
spawner: ctx.spawner(),
uuid: installation_uuid,
persisted_credentials,
is_headless,
is_file_based,
};
let server_name = server.name.clone();
let description = installation.templatable_mcp_server().description.clone();
let auth_context = FeatureFlag::McpOauth.is_enabled().then(|| {
let persist_spawner = ctx.spawner();
let requires_authentication_spawner = ctx.spawner();
let authenticated_spawner = ctx.spawner();
AuthContext {
oauth_result_rx,
uuid: installation_uuid,
persisted_credentials,
is_headless,
is_file_based,
persist_credentials: Box::new(move |installation_uuid, credentials| {
let spawner = persist_spawner.clone();
Box::pin(async move {
spawner
.spawn(move |manager, ctx| {
manager.save_credentials_to_secure_storage(
ctx,
installation_uuid,
credentials,
);
})
.await
.map_err(|err| {
anyhow::anyhow!(
"Failed to persist auto-refreshed MCP credentials: {err:?}"
)
})?;
Ok(())
})
}),
requires_authentication: Box::new(move |uuid, csrf_state, auth_url| {
let spawner = requires_authentication_spawner.clone();
Box::pin(async move {
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
.map_err(|err| {
anyhow::anyhow!(
"Failed to emit MCP authentication-required state: {err:?}"
)
})?;
Ok(())
})
}),
authenticated: Some(Box::new(move |server_name| {
let spawner = authenticated_spawner.clone();
Box::pin(async move {
spawner
.spawn(move |_, ctx| {
if let Some(active_window_id) = ctx.windows().active_window() {
ToastStack::handle(ctx).update(ctx, |stack, ctx| {
stack.add_ephemeral_toast(
DismissibleToast::default(format!(
"Successfully authenticated {server_name} MCP server"
)),
active_window_id,
ctx,
);
});
}
})
.await
.map_err(|err| {
anyhow::anyhow!(
"Failed to emit MCP authenticated notification: {err:?}"
)
})?;
Ok(())
})
})),
}
});
// Extract values from mode before moving it into the closure.
let should_persist = mode.should_persist_running_state_to_sqlite();
@@ -872,7 +986,7 @@ impl TemplatableMCPServerManager {
let error = match server_info {
Ok(info) => {
let peer = info.service.clone();
let peer = info.peer();
me.active_servers.insert(installation_uuid, info);
// Clear any previous error message on successful connection.
@@ -966,7 +1080,7 @@ impl TemplatableMCPServerManager {
if let Some(server_info) = self.active_servers.remove(&installation_uuid) {
self.change_server_state(installation_uuid, MCPServerState::ShuttingDown, ctx);
// Cancel the server, and emit NotRunning state once it has stopped.
ctx.spawn(server_info.service.cancel(), move |me, _, ctx| {
ctx.spawn(server_info.shutdown(), move |me, _, ctx| {
me.change_server_state(installation_uuid, MCPServerState::NotRunning, ctx);
ctx.dispatch_global_action("workspace:save_app", ());
});
@@ -1390,6 +1504,7 @@ impl TemplatableMCPServerManager {
let ParsedTemplatableMCPServerResult {
templatable_mcp_server,
templatable_mcp_server_installation,
..
} = parsed_result.clone();
let template_uuid = templatable_mcp_server.uuid;
self.create_templatable_mcp_server(templatable_mcp_server, space, initiated_by, ctx);
@@ -1419,8 +1534,6 @@ impl TemplatableMCPServerManager {
servers_to_restart: HashSet<Uuid>,
ctx: &mut ModelContext<Self>,
) {
// Import inline because of circular dependencies
use crate::ai::mcp::CloudMCPServer;
let cloud_legacy_servers = CloudMCPServer::get_all(ctx);
log::info!(
"Converting {} legacy MCP servers into templatable MCP servers",
@@ -1550,13 +1663,7 @@ impl TemplatableMCPServerManager {
) -> Option<rmcp::Peer<rmcp::RoleClient>> {
self.active_servers
.get(&installation_uuid)
.and_then(|server| {
if server.service.is_transport_closed() {
None
} else {
Some(server.service.clone())
}
})
.and_then(TemplatableMCPServerInfo::peer_if_connected)
}
/// Triggers reconnection of a server by its installation UUID.
@@ -1640,7 +1747,7 @@ impl TemplatableMCPServerManager {
let spawner = self.spawner.as_ref()?;
self.active_servers
.iter()
.find(|(_, server)| server.tools.iter().any(|t| t.name == tool_name))
.find(|(_, server)| server.has_tool(&tool_name))
.map(|(installation_uuid, _)| {
crate::ai::mcp::reconnecting_peer::ReconnectingPeer::new(
*installation_uuid,
@@ -1659,7 +1766,7 @@ impl TemplatableMCPServerManager {
) -> Option<crate::ai::mcp::reconnecting_peer::ReconnectingPeer> {
let spawner = self.spawner.as_ref()?;
let server = self.active_servers.get(&installation_id)?;
if server.tools.iter().any(|t| t.name == tool_name) {
if server.has_tool(&tool_name) {
Some(crate::ai::mcp::reconnecting_peer::ReconnectingPeer::new(
installation_id,
spawner.clone(),
@@ -1675,15 +1782,25 @@ impl TemplatableMCPServerManager {
ctx: &mut ModelContext<Self>,
) {
// First, check if the servers are already spawned.
let new_installations = installations
.iter()
.filter(|installation| {
let uuid = installation.uuid();
!self.active_servers.contains_key(&uuid)
&& !self.spawned_servers.contains_key(&uuid)
})
.cloned()
.collect_vec();
let mut new_installations = Vec::new();
for installation in installations {
let uuid = installation.uuid();
let server_name = &installation.templatable_mcp_server().name;
if self.active_servers.contains_key(&uuid) {
log::info!(
"Skipping file-based MCP server '{server_name}' ({uuid}); already running"
);
continue;
}
if self.spawned_servers.contains_key(&uuid) {
log::info!(
"Skipping file-based MCP server '{server_name}' ({uuid}); already starting"
);
continue;
}
log::info!("Spawning file-based MCP server '{server_name}' ({uuid})");
new_installations.push(installation.clone());
}
// If not, spawn them.
for installation in new_installations {
@@ -1721,440 +1838,3 @@ impl TemplatableMCPServerManager {
.contains_key(&installation_hash)
}
}
type ReqwestHttpTransport = rmcp::transport::StreamableHttpClientTransport<reqwest::Client>;
type ReqwestSseTransport = rmcp::transport::SseClientTransport<reqwest::Client>;
/// Spawns a new MCP server from a given [`TransportType`].
async fn spawn_server(
server_name: String,
description: Option<String>,
uuid: Uuid,
transport_type: TransportType,
logger: SimpleLogger,
auth_context: AuthContext,
) -> Result<TemplatableMCPServerInfo, rmcp::RmcpError> {
logger.log("[note] Attention! There may be sensitive information (such as API keys) in these logs. Make sure to redact any secrets before sharing with others.".to_string());
let mut is_authenticated_transport = false;
let service = match transport_type {
TransportType::CLIServer(cli_server) => {
logger.log("[info] MCP: Using stdio transport".to_string());
cfg_if! {
if #[cfg(windows)] {
// We wrap the command in cmd.exe /c to allow Windows to be responsible for resolving the
// PATH variable rather than depending on the `Command` implementation, which only looks for
// `.exe` files in directories found in PATH.
// https://github.com/rust-lang/rust/issues/37519
let command = "cmd.exe".to_owned();
let args = std::iter::once("/c".to_owned())
.chain(std::iter::once(cli_server.command))
.chain(cli_server.args)
.collect::<Vec<String>>();
} else {
let command = cli_server.command;
let args = cli_server.args;
}
}
// Capture the command and configured cwd for diagnostics before they're
// moved into the Command builder closure.
let command_for_log = command.clone();
let cwd_for_log = cli_server.cwd_parameter.clone();
// Try to spawn the child process.
let (transport, stderr) = rmcp::transport::TokioChildProcess::builder(
tokio::process::Command::new(command).configure(|cmd| {
cmd.args(args);
if let Some(cwd) = cli_server.cwd_parameter {
cmd.current_dir(cwd);
}
for StaticEnvVar { name, value } in cli_server.static_env_vars.iter() {
if value.is_empty() {
// Skip empty/unset environment variables so that, in the CLI, they can be inherited.
logger.log(format!(
"[warn] MCP: Skipping empty environment variable: {name}"
));
continue;
}
cmd.env(name, value);
}
// On Windows, ensure that no console window is shown.
#[cfg(windows)]
cmd.creation_flags(windows::Win32::System::Threading::CREATE_NO_WINDOW.0);
}),
)
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
let cwd_display = cwd_for_log
.as_deref()
.unwrap_or("<inherited from Warp's process cwd>");
logger.log(format!(
"[error] MCP: Failed to spawn '{server_name}': command '{command_for_log}' \
not found (cwd: {cwd_display}). If your MCP server depends on a specific \
working directory, set the `working_directory` field in your config to \
override the default."
));
}
rmcp::RmcpError::transport_creation::<rmcp::transport::TokioChildProcess>(err)
})?;
let pid = transport
.id()
.map(|pid| pid.to_string())
.unwrap_or("??".to_string());
// We always expect to have an stderr, but this is marginally safer than unwrapping.
if let Some(stderr) = stderr {
let logger = logger.clone();
// Spawn a background task to forward from the child process's stderr to our logger.
tokio::spawn(async move {
let mut buf = String::new();
let mut reader = tokio::io::BufReader::new(stderr);
loop {
match reader.read_line(&mut buf).await {
// EOF.
Ok(0) => return,
// Read some data.
Ok(_) => logger.log(format!("[info] MCP [pid: {pid}] stderr: {buf}")),
// Failed to read from the child process's stderr.
Err(e) => {
log::error!("Failed to read stderr: {e}");
return;
}
}
}
});
}
// Wrap the transport in a logging wrapper.
let transport = TransportLoggingWrapper {
transport,
logger: logger.clone(),
};
// Create the MCP client and connect to the server.
Ok::<_, rmcp::RmcpError>(make_client_info().into_dyn().serve(transport).await?)
}
TransportType::ServerSentEvents(sse_server) => {
let headers: std::collections::HashMap<String, String> = sse_server
.headers
.iter()
.map(|h| (h.name.clone(), h.value.clone()))
.collect();
match determine_transport(server_name.clone(), &sse_server.url, &headers, auth_context)
.await
{
// TODO: these need headers also?
Ok(Transport::Http(Some(client))) => {
is_authenticated_transport = true;
logger.log("[info] MCP: Using Streaming HTTP transport".to_string());
let transport = rmcp::transport::StreamableHttpClientTransport::with_client(
client,
rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(
sse_server.url.clone(),
),
);
let transport = TransportLoggingWrapper {
transport,
logger: logger.clone(),
};
Ok(make_client_info().into_dyn().serve(transport).await?)
}
Ok(Transport::Http(None)) => {
logger.log("[info] MCP: Using Streaming HTTP transport".to_string());
let transport = if headers.is_empty() {
rmcp::transport::StreamableHttpClientTransport::from_uri(
sse_server.url.clone(),
)
} else {
let client = build_client_with_headers(&headers)?;
rmcp::transport::StreamableHttpClientTransport::with_client(
client,
rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(
sse_server.url.clone(),
),
)
};
let transport = TransportLoggingWrapper {
transport,
logger: logger.clone(),
};
Ok(make_client_info().into_dyn().serve(transport).await?)
}
Ok(Transport::Sse(Some(client))) => {
is_authenticated_transport = true;
logger.log("[info] MCP: Using (legacy) SSE transport (due to preflight failing with a 404)".to_string());
let transport = rmcp::transport::SseClientTransport::start_with_client(
client,
rmcp::transport::sse_client::SseClientConfig {
sse_endpoint: sse_server.url.into(),
..Default::default()
},
)
.await
.map_err(rmcp::RmcpError::transport_creation::<ReqwestSseTransport>)?;
let transport = TransportLoggingWrapper {
transport,
logger: logger.clone(),
};
Ok(make_client_info().into_dyn().serve(transport).await?)
}
Ok(Transport::Sse(None)) => {
logger.log("[info] MCP: Using (legacy) SSE transport (due to preflight failing with a 404)".to_string());
let transport = if headers.is_empty() {
rmcp::transport::SseClientTransport::start(sse_server.url.clone())
.await
.map_err(|e| {
rmcp::RmcpError::transport_creation::<ReqwestSseTransport>(e)
})?
} else {
let client = build_client_with_headers(&headers)?;
rmcp::transport::SseClientTransport::start_with_client(
client,
rmcp::transport::sse_client::SseClientConfig {
sse_endpoint: sse_server.url.clone().into(),
..Default::default()
},
)
.await
.map_err(rmcp::RmcpError::transport_creation::<ReqwestSseTransport>)?
};
let transport = TransportLoggingWrapper {
transport,
logger: logger.clone(),
};
Ok(make_client_info().into_dyn().serve(transport).await?)
}
Err(err) => {
logger.log(format!(
"[error] MCP: preflight connection to MCP server failed: {err:#}"
));
Err(err)?
}
}
}
}?;
let server_info = service.peer_info();
logger.log(format!("[info] MCP: Connected to server: {server_info:#?}"));
let resources = if server_info.is_some_and(|info| info.capabilities.resources.is_some()) {
match service.list_all_resources().await {
Ok(result) => result,
Err(err) => {
log::warn!("Failed to list resources for MCP server '{server_name}': {err}");
vec![]
}
}
} else {
vec![]
};
let tools = match service.list_all_tools().await {
Ok(result) => result,
Err(rmcp::ServiceError::McpError(rmcp::model::ErrorData { code, .. }))
if code == rmcp::model::ErrorCode::METHOD_NOT_FOUND =>
{
vec![]
}
Err(err) => {
return Err(err.into());
}
};
Ok(TemplatableMCPServerInfo {
name: server_name,
service,
resources,
tools,
installation_id: uuid,
description,
is_authenticated_transport,
})
}
/// The transport to use for MCP.
enum Transport {
/// The HTTP transport, with an optional authenticated client.
Http(Option<rmcp::transport::auth::AuthClient<reqwest::Client>>),
/// The SSE transport, with an optional authenticated client.
Sse(Option<rmcp::transport::auth::AuthClient<reqwest::Client>>),
}
/// Determines which transport to use.
///
/// This sends a "preflight" InitializeRequest to the server to determine whether the
/// server supports the HTTP transport (or needs to use the SSE transport), and if
/// authentication is required.
async fn determine_transport(
server_name: String,
url: &str,
headers: &std::collections::HashMap<String, String>,
auth_context: AuthContext,
) -> Result<Transport, rmcp::RmcpError> {
use reqwest::StatusCode;
fn unexpected_error(status: reqwest::StatusCode) -> rmcp::RmcpError {
rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>(format!(
"Unexpected status code: {status}"
))
}
match send_initialize_request(url, headers, None).await? {
StatusCode::OK => Ok(Transport::Http(None)),
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => Ok(Transport::Sse(None)),
StatusCode::UNAUTHORIZED => {
if !FeatureFlag::McpOauth.is_enabled() {
return Err(rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>(
"Server requires authentication, which is not yet supported.".to_string(),
));
}
let spawner = auth_context.spawner.clone();
// Go through the OAuth flow to get an authenticated client.
// This will first attempt to use cached credentials before starting interactive OAuth.
let (client, did_require_login) = oauth::make_authenticated_client(url, auth_context)
.boxed()
.await
.map_err(rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>)?;
let transport = match send_initialize_request(url, headers, Some(&client)).await? {
StatusCode::OK => Ok(Transport::Http(Some(client))),
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => {
Ok(Transport::Sse(Some(client)))
}
other => Err(unexpected_error(other)),
};
if transport.is_ok() && did_require_login {
let _ = spawner
.spawn(move |_, ctx| {
if let Some(active_window_id) = ctx.windows().active_window() {
ToastStack::handle(ctx).update(ctx, |stack, ctx| {
stack.add_ephemeral_toast(
DismissibleToast::default(format!(
"Successfully authenticated {server_name} MCP server"
)),
active_window_id,
ctx,
);
});
}
})
.await;
}
transport
}
status => Err(unexpected_error(status)),
}
}
/// Sends an InitializeRequest to the server, and returns the HTTP status code from the response.
async fn send_initialize_request(
url: &str,
headers: &std::collections::HashMap<String, String>,
auth_client: Option<&rmcp::transport::auth::AuthClient<reqwest::Client>>,
) -> Result<reqwest::StatusCode, rmcp::RmcpError> {
use rmcp::transport::common::http_header::{EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE};
let request = rmcp::model::InitializeRequest::new(make_client_info());
let request = rmcp::model::ClientJsonRpcMessage::request(
rmcp::model::ClientRequest::InitializeRequest(request),
rmcp::model::RequestId::Number(0),
);
let mut request = build_client_with_headers(headers)?
.post(url)
.header(
http::header::ACCEPT,
[EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", "),
)
.json(&request);
if let Some(auth_client) = auth_client.as_ref() {
let access_token = auth_client
.get_access_token()
.await
.map_err(rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>)?;
request = request.bearer_auth(access_token);
}
let response = request
.send()
.await
.map_err(rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>)?;
Ok(response.status())
}
/// Creates a [`ClientInfo`] for the MCP client.
///
/// This tells the MCP server who we are and what capabilities we have.
fn make_client_info() -> rmcp::model::ClientInfo {
rmcp::model::ClientInfo {
protocol_version: Default::default(),
capabilities: Default::default(),
client_info: rmcp::model::Implementation {
name: galaxy_core::channel::ChannelState::app_id().to_string(),
version: galaxy_core::channel::ChannelState::app_version()
.map(|v| v.to_string())
.unwrap_or_default(),
title: None,
icons: None,
website_url: None,
},
}
}
/// A wrapper around a [`rmcp::transport::Transport`] that logs all requests and responses.
struct TransportLoggingWrapper<T> {
transport: T,
logger: SimpleLogger,
}
impl<T: rmcp::transport::Transport<R>, R: rmcp::service::ServiceRole> rmcp::transport::Transport<R>
for TransportLoggingWrapper<T>
{
type Error = T::Error;
fn send(
&mut self,
item: rmcp::service::TxJsonRpcMessage<R>,
) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static {
if let Ok(json) = serde_json::to_string(&item) {
self.logger
.log(format!("[info] MCP: Sending request: {json}"));
}
let logger = self.logger.clone();
self.transport.send(item).map(move |result| {
if let Err(e) = &result {
logger.log(format!("[warn] MCP: Failed to send request: {e:#}"));
}
result
})
}
fn receive(
&mut self,
) -> impl Future<Output = Option<rmcp::service::RxJsonRpcMessage<R>>> + Send {
let logger = self.logger.clone();
async move {
let result = self.transport.receive().await;
if let Some(item) = &result {
if let Ok(json) = serde_json::to_string(item) {
logger.log(format!("[info] MCP: Received response: {json}"));
}
}
result
}
}
fn close(&mut self) -> impl Future<Output = Result<(), Self::Error>> + Send {
self.transport.close()
}
}
+4 -4
View File
@@ -1,5 +1,7 @@
use galaxyui::AppContext;
use galaxyui::ModelContext;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
use galaxyui::{AppContext, ModelContext};
use super::TemplatableMCPServerManager;
use crate::ai::mcp::templatable::{CloudTemplatableMCPServer, TemplatableMCPServer};
@@ -8,8 +10,6 @@ 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.