first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user