use std::collections::HashMap; use uuid::Uuid; use galaxyui::{ elements::{ChildView, Container}, ui_components::components::{Coords, UiComponentStyles}, AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use crate::{ ai::mcp::{ gallery::MCPGalleryManager, templatable_installation::VariableValue, FileBasedMCPManager, TemplatableMCPServer, TemplatableMCPServerInstallation, TemplatableMCPServerManager, }, appearance::Appearance, cloud_object::Space, modal::{Modal, ModalViewState}, server::cloud_objects::update_manager::InitiatedBy, settings_view::{ mcp_servers::{ edit_page::{MCPServersEditPageView, MCPServersEditPageViewEvent}, installation_modal::{InstallationModalBody, InstallationModalBodyEvent}, list_page::{MCPServersListPageView, MCPServersListPageViewEvent}, style, ServerCardItemId, }, settings_page::{MatchData, PageType, SettingsPageMeta, SettingsWidget}, SettingsSection, }, view_components::DismissibleToast, workspace::ToastStack, }; /// Describes where an MCP install request originated. /// /// Used to decide whether an install request is allowed to bypass the /// installation modal. In-app gestures (gallery card click, reinstall button) /// are implicitly confirmed by the click itself. Deeplink-triggered installs /// are untrusted and must always route through the installation modal so the /// user can explicitly confirm before any installation or server spawn occurs. /// See `specs/GH686/product.md`. #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum InstallOrigin { /// Triggered by a user gesture inside Warp (gallery card click, /// reinstall button, programmatic in-app flows, etc.). InApp, /// Triggered by a `warp://settings/mcp?autoinstall=...` deeplink; must be /// gated by an explicit in-app confirmation before install or spawn. Deeplink, } const PAGE_TITLE_TEXT: &str = "MCP Servers"; #[derive(Debug, Default, Copy, Clone)] pub enum MCPServersSettingsPage { #[default] List, Edit { item_id: Option, }, } #[derive(Debug, Clone)] pub enum MCPServersSettingsPageEvent { ShowModal, HideModal, } pub struct MCPServersSettingsPageView { page: PageType, current_page: MCPServersSettingsPage, list_view: ViewHandle, edit_view: ViewHandle, installation_modal_state: ModalViewState>, } impl MCPServersSettingsPageView { pub fn new(ctx: &mut ViewContext) -> Self { let list_view = ctx.add_typed_action_view(MCPServersListPageView::new); ctx.subscribe_to_view(&list_view, |me, _, event, ctx| { me.handle_list_view_event(event, ctx); }); let edit_view = ctx.add_typed_action_view(MCPServersEditPageView::new); ctx.subscribe_to_view(&edit_view, |me, _, event, ctx| { me.handle_edit_view_event(event, ctx); }); let installation_modal_body = ctx.add_typed_action_view(|_ctx| InstallationModalBody::new()); ctx.subscribe_to_view(&installation_modal_body, |me, _, event, ctx| { me.handle_installation_modal_body_event(event, ctx); }); let installation_modal = ctx.add_typed_action_view(|ctx| { Modal::new(None, installation_modal_body, ctx).with_body_style(UiComponentStyles { padding: Some(Coords::uniform(0.)), ..Default::default() }) }); let installation_modal_state = ModalViewState::new(installation_modal); Self { page: PageType::new_monolith( MCPServersSettingsWidget::default(), Some(PAGE_TITLE_TEXT), true, ), current_page: MCPServersSettingsPage::default(), list_view, edit_view, installation_modal_state, } } pub fn update_page(&mut self, page: MCPServersSettingsPage, ctx: &mut ViewContext) { self.current_page = page; if let MCPServersSettingsPage::Edit { item_id } = page { self.edit_view.update(ctx, |edit_view, ctx| { edit_view.set_mcp_server(item_id, ctx); }); } self.focus(ctx); ctx.notify(); } pub fn focus(&mut self, ctx: &mut ViewContext) { match self.current_page { MCPServersSettingsPage::List => ctx.focus(&self.list_view), MCPServersSettingsPage::Edit { .. } => ctx.focus(&self.edit_view), } } fn add_toast(&mut self, message: &str, ctx: &mut ViewContext) { let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast( DismissibleToast::default(message.to_string()), window_id, ctx, ); }); } fn handle_log_out( &mut self, item_id: ServerCardItemId, server_name: Option, ctx: &mut ViewContext, ) { let message = match server_name { Some(name) => format!("Successfully logged out of {name} MCP server"), None => "Successfully logged out of MCP server".to_string(), }; match item_id { ServerCardItemId::TemplatableMCP(_) => { log::error!("Logging out is not supported for template MCP servers."); } ServerCardItemId::TemplatableMCPInstallation(uuid) => { TemplatableMCPServerManager::handle(ctx).update(ctx, |manager, ctx| { manager.delete_credentials_from_secure_storage(uuid, ctx); manager.shutdown_server(uuid, ctx); }); self.add_toast(&message, ctx); } ServerCardItemId::GalleryMCP(_) => { log::error!("Logging out is not supported for gallery MCP servers."); } ServerCardItemId::FileBasedMCP(uuid) => { if let Some(installation) = FileBasedMCPManager::as_ref(ctx).get_installation_by_uuid(uuid) { if let Some(hash) = installation.hash() { TemplatableMCPServerManager::handle(ctx).update(ctx, |manager, ctx| { manager.shutdown_server(uuid, ctx); manager.purge_file_based_server_credentials(&vec![hash], ctx); }); } } self.add_toast(&message, ctx); } } } fn start_server_installation( &mut self, templatable_mcp_server: TemplatableMCPServer, instructions_in_markdown: Option, origin: InstallOrigin, ctx: &mut ViewContext, ) { let has_variables = !templatable_mcp_server.template.variables.is_empty(); let has_instructions = instructions_in_markdown.is_some(); let should_show_modal = Self::should_show_install_modal(origin, has_variables, has_instructions); if should_show_modal { self.installation_modal_state .view .update(ctx, |modal, ctx| { modal.body().update(ctx, |body, ctx| { body.set_templatable_mcp_server( Some(templatable_mcp_server), instructions_in_markdown, ctx, ) }); }); self.installation_modal_state.open(); ctx.focus(&self.installation_modal_state.view); ctx.emit(MCPServersSettingsPageEvent::ShowModal); } else { self.process_server_installation(&templatable_mcp_server, HashMap::new(), ctx); } ctx.notify(); } /// Decides whether an install request should route through the installation /// modal. Deeplink-origin requests always require explicit confirmation via /// the modal, regardless of template shape. In-app requests keep the /// pre-existing heuristic where the modal is only shown when the template /// has variables or markdown instructions; the click gesture itself is the /// user confirmation. See `specs/GH686/product.md`. pub(crate) fn should_show_install_modal( origin: InstallOrigin, has_variables: bool, has_instructions: bool, ) -> bool { match origin { InstallOrigin::Deeplink => true, InstallOrigin::InApp => has_variables || has_instructions, } } fn process_server_installation( &mut self, templatable_mcp_server: &TemplatableMCPServer, variable_values: HashMap, ctx: &mut ViewContext, ) -> Option { TemplatableMCPServerManager::handle(ctx).update(ctx, |templatable_manager, ctx| { if templatable_manager .get_cloud_server(templatable_mcp_server.uuid, ctx) .is_none() { templatable_manager.create_templatable_mcp_server( templatable_mcp_server.clone(), Space::Personal, InitiatedBy::User, ctx, ); } let installation = templatable_manager.install_from_template( templatable_mcp_server.clone(), variable_values.clone(), true, ctx, ); ctx.notify(); installation }) } pub fn reinstall_server(&mut self, installation_uuid: Uuid, ctx: &mut ViewContext) { let template_uuid = TemplatableMCPServerManager::as_ref(ctx).get_template_uuid(installation_uuid); if let Some(template_uuid) = template_uuid { let templatable_mcp_server = TemplatableMCPServerManager::as_ref(ctx).get_templatable_mcp_server(template_uuid); if let Some(templatable_mcp_server) = templatable_mcp_server { // Reinstall is always an in-app action triggered from the edit page // reinstall button; it must not pick up the deeplink confirmation gating. self.start_server_installation( templatable_mcp_server.clone(), None, InstallOrigin::InApp, ctx, ); } } } /// Emits an error toast in the current window. fn add_error_toast(&mut self, message: String, ctx: &mut ViewContext) { let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast(DismissibleToast::error(message), window_id, ctx); }); } /// Auto-installs an MCP server from the gallery. /// /// This is the single sink for `warp://settings/mcp?autoinstall=` /// deeplinks; callers must therefore treat the `autoinstall_param` as /// untrusted input. The `autoinstall_param` is matched case-insensitively /// against gallery titles. /// /// Every deeplink autoinstall is routed through the installation modal and /// requires an explicit user confirmation before any installation or /// server spawn occurs — even for templates with no variables and no /// markdown instructions. See `specs/GH686/product.md`. pub fn autoinstall_from_gallery( &mut self, autoinstall_param: &str, ctx: &mut ViewContext<Self>, ) { log::info!("Received MCP deeplink autoinstall for value '{autoinstall_param}'"); // Concurrent deeplink guard: if a prior installation modal is still // open, surface a toast and bail so we do not silently overwrite the // current modal contents with a different template. See product // invariant 7 in specs/GH686/product.md. if self.installation_modal_state.is_open() { log::warn!( "Ignoring MCP deeplink autoinstall for '{autoinstall_param}': installation modal already open" ); self.add_error_toast( "Finish the current MCP install before opening another install link.".to_string(), ctx, ); return; } let autoinstall_lower = autoinstall_param.to_lowercase(); let gallery_server = MCPGalleryManager::as_ref(ctx) .get_gallery() .into_iter() .find(|item| item.title().to_lowercase() == autoinstall_lower); let Some(gallery_server) = gallery_server else { log::warn!( "Unrecognized autoinstall value '{autoinstall_param}': no matching gallery item found" ); self.add_error_toast(format!("Unknown MCP server '{autoinstall_param}'"), ctx); return; }; // Skip if this gallery item is already installed. let gallery_uuid = gallery_server.uuid(); let already_installed = TemplatableMCPServerManager::as_ref(ctx) .get_installed_templatable_servers() .values() .any(|installation| installation.gallery_uuid() == Some(gallery_uuid)); if already_installed { log::info!( "Gallery MCP server '{}' is already installed, skipping autoinstall", gallery_server.title() ); return; } let instructions = gallery_server.instructions_in_markdown().cloned(); let gallery_title = gallery_server.title().to_string(); let Ok(templatable_mcp_server) = TemplatableMCPServer::try_from(gallery_server) else { log::warn!( "Failed to convert gallery item '{autoinstall_param}' to TemplatableMCPServer" ); // Invariant 5 (specs/GH686/product.md): the match succeeded but the // gallery entry cannot be turned into a valid template. Surface the // failure to the user rather than silently returning. self.add_error_toast( format!("MCP server '{gallery_title}' cannot be installed from this link."), ctx, ); return; }; log::info!("Opening MCP install confirmation for deeplink gallery title '{gallery_title}'"); self.start_server_installation( templatable_mcp_server, instructions, InstallOrigin::Deeplink, ctx, ); } fn handle_list_view_event( &mut self, event: &MCPServersListPageViewEvent, ctx: &mut ViewContext<Self>, ) { match event { MCPServersListPageViewEvent::Edit(mcp_item_id) => { self.update_page( MCPServersSettingsPage::Edit { item_id: Some(*mcp_item_id), }, ctx, ); } MCPServersListPageViewEvent::Add => { self.update_page(MCPServersSettingsPage::Edit { item_id: None }, ctx); } MCPServersListPageViewEvent::LogOut(server_card_item_id, server_name) => { self.handle_log_out(*server_card_item_id, Some(server_name.clone()), ctx); } MCPServersListPageViewEvent::StartInstallation { templatable_mcp_server: template, instructions_in_markdown, origin, } => { self.start_server_installation( template.clone(), instructions_in_markdown.clone(), *origin, ctx, ); } MCPServersListPageViewEvent::ShowModal => { ctx.emit(MCPServersSettingsPageEvent::ShowModal); } MCPServersListPageViewEvent::HideModal => { ctx.emit(MCPServersSettingsPageEvent::HideModal); } } } fn handle_edit_view_event( &mut self, event: &MCPServersEditPageViewEvent, ctx: &mut ViewContext<Self>, ) { match event { MCPServersEditPageViewEvent::Back => { self.update_page(MCPServersSettingsPage::List, ctx); } MCPServersEditPageViewEvent::Reinstall(template_uuid) => { self.reinstall_server(*template_uuid, ctx); } MCPServersEditPageViewEvent::Delete(item_id) => { self.list_view.update(ctx, |list_view, ctx| { list_view.delete_server(*item_id, ctx); }); self.update_page(MCPServersSettingsPage::List, ctx); } MCPServersEditPageViewEvent::LogOut(server_card_item_id, server_name) => { self.handle_log_out(*server_card_item_id, server_name.clone(), ctx); } } } pub fn get_modal_content(&self, app: &AppContext) -> Option<Box<dyn Element>> { if self.installation_modal_state.is_open() { Some(self.installation_modal_state.render()) } else { match self.current_page { MCPServersSettingsPage::List => self .list_view .read(app, |list_view, _| list_view.get_modal_content()), MCPServersSettingsPage::Edit { .. } => None, } } } fn handle_installation_modal_body_event( &mut self, event: &InstallationModalBodyEvent, ctx: &mut ViewContext<Self>, ) { match event { InstallationModalBodyEvent::Install(templatable_mcp_server, variable_values) => { // Uninstall the old copy with outdated variable values TemplatableMCPServerManager::handle(ctx).update(ctx, |manager, ctx| { let old_installation = manager.get_installation_by_template_uuid(templatable_mcp_server.uuid); if let Some(old_installation) = old_installation { let old_installation_uuid = old_installation.uuid(); manager .delete_templatable_mcp_server_installation(old_installation_uuid, ctx); ctx.notify(); }; }); // Install the copy with new variables let new_installation = self.process_server_installation( templatable_mcp_server, variable_values.clone(), ctx, ); // When we re-install, the installation uuid changes, so we should load the edit page with the new installation uuid if let Some(new_installation) = new_installation { self.edit_view.update(ctx, |edit_page, ctx| { edit_page.set_mcp_server( Some(ServerCardItemId::TemplatableMCPInstallation( new_installation.uuid(), )), ctx, ); }); } self.installation_modal_state .view .update(ctx, |modal, ctx| { modal.body().update(ctx, |body, ctx| { body.set_templatable_mcp_server(None, None, ctx) }); }); self.installation_modal_state.close(); ctx.emit(MCPServersSettingsPageEvent::HideModal); ctx.notify(); } InstallationModalBodyEvent::Cancel => { self.installation_modal_state.close(); ctx.emit(MCPServersSettingsPageEvent::HideModal); ctx.notify(); } } } } impl Entity for MCPServersSettingsPageView { type Event = MCPServersSettingsPageEvent; } impl View for MCPServersSettingsPageView { fn ui_name() -> &'static str { "MCPServersSettingsPageView" } fn render(&self, _app: &AppContext) -> Box<dyn Element> { match self.current_page { MCPServersSettingsPage::List => self.page.render(self, _app), MCPServersSettingsPage::Edit { item_id: _ } => { // The edit view needs to be constrained so we will render it directly // instead of rendering inside the settings widget Container::new(ChildView::new(&self.edit_view).finish()) .with_uniform_padding(style::PAGE_PADDING) .finish() } } } } impl TypedActionView for MCPServersSettingsPageView { type Action = (); } impl SettingsPageMeta for MCPServersSettingsPageView { fn section() -> SettingsSection { SettingsSection::MCPServers } fn should_render(&self, _ctx: &AppContext) -> bool { true } fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData { self.page.update_filter(query, ctx) } fn scroll_to_widget(&mut self, widget_id: &'static str) { self.page.scroll_to_widget(widget_id) } fn clear_highlighted_widget(&mut self) { self.page.clear_highlighted_widget() } } #[cfg(test)] #[path = "mcp_servers_page_tests.rs"] mod tests; #[derive(Default)] pub struct MCPServersSettingsWidget { // No state yet } impl SettingsWidget for MCPServersSettingsWidget { type View = MCPServersSettingsPageView; fn search_terms(&self) -> &str { "mcp servers" } fn render( &self, view: &Self::View, _appearance: &Appearance, _app: &AppContext, ) -> Box<dyn Element> { // The settings widget will always return list view // The edit view needs to be constrained so we will render it directly // instead of rendering inside the settings widget ChildView::new(&view.list_view).finish() } }