From 804b91aa8c48a702bbddc9cd94e53e4498cd0d93 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 18 Jun 2025 19:52:40 -0300 Subject: [PATCH] agent: Improve the UX around interacting with MCP servers (#32622) Still a work in progress! Todos before merging: - [x] Allow to delete (not just turn off) an MCP server from the panel's settings view - [x] Also uninstall the extension upon deleting the server (check if the extension just provides MCPs) - [x] Resolve repository URL again - [x] Add a button to open the configuration modal from the panel's settings view - [x] Improve modal UX to install and configure a non-extension MCP Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: Ben Brandt --- Cargo.lock | 2 +- assets/icons/zed_mcp_custom.svg | 4 + assets/icons/zed_mcp_extension.svg | 4 + crates/agent/Cargo.toml | 2 +- crates/agent/src/agent.rs | 9 +- crates/agent/src/agent_configuration.rs | 266 ++++- .../add_context_server_modal.rs | 195 ---- .../configure_context_server_modal.rs | 987 +++++++++++------- crates/agent/src/agent_panel.rs | 13 +- .../agent/src/context_server_configuration.rs | 111 +- crates/extension_host/src/extension_host.rs | 9 +- .../src/extension_store_test.rs | 4 +- crates/extensions_ui/src/extensions_ui.rs | 6 +- crates/icons/src/icons.rs | 2 + crates/project/src/context_server_store.rs | 7 + crates/settings/src/settings_store.rs | 33 +- 16 files changed, 926 insertions(+), 728 deletions(-) create mode 100644 assets/icons/zed_mcp_custom.svg create mode 100644 assets/icons/zed_mcp_extension.svg delete mode 100644 crates/agent/src/agent_configuration/add_context_server_modal.rs diff --git a/Cargo.lock b/Cargo.lock index f37877bf2f..27e91430c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,7 @@ dependencies = [ "db", "editor", "extension", + "extension_host", "feature_flags", "file_icons", "fs", @@ -127,7 +128,6 @@ dependencies = [ "time", "time_format", "ui", - "ui_input", "urlencoding", "util", "uuid", diff --git a/assets/icons/zed_mcp_custom.svg b/assets/icons/zed_mcp_custom.svg new file mode 100644 index 0000000000..6410a26fca --- /dev/null +++ b/assets/icons/zed_mcp_custom.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/zed_mcp_extension.svg b/assets/icons/zed_mcp_extension.svg new file mode 100644 index 0000000000..996e0c1920 --- /dev/null +++ b/assets/icons/zed_mcp_extension.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 66e4a5c78f..69edd5a189 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -36,6 +36,7 @@ convert_case.workspace = true db.workspace = true editor.workspace = true extension.workspace = true +extension_host.workspace = true feature_flags.workspace = true file_icons.workspace = true fs.workspace = true @@ -90,7 +91,6 @@ thiserror.workspace = true time.workspace = true time_format.workspace = true ui.workspace = true -ui_input.workspace = true urlencoding.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 94edf76e97..ff108e06cb 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -46,7 +46,7 @@ use settings::{Settings as _, SettingsStore}; use thread::ThreadId; pub use crate::active_thread::ActiveThread; -use crate::agent_configuration::{AddContextServerModal, ManageProfilesModal}; +use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate}; pub use crate::context::{ContextLoadResult, LoadedContext}; pub use crate::inline_assistant::InlineAssistant; @@ -162,7 +162,7 @@ pub fn init( assistant_slash_command::init(cx); thread_store::init(cx); agent_panel::init(cx); - context_server_configuration::init(language_registry, fs.clone(), cx); + context_server_configuration::init(language_registry.clone(), fs.clone(), cx); register_slash_commands(cx); inline_assistant::init( @@ -178,7 +178,10 @@ pub fn init( cx, ); indexed_docs::init(cx); - cx.observe_new(AddContextServerModal::register).detach(); + cx.observe_new(move |workspace, window, cx| { + ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx) + }) + .detach(); cx.observe_new(ManageProfilesModal::register).detach(); } diff --git a/crates/agent/src/agent_configuration.rs b/crates/agent/src/agent_configuration.rs index 1091e16a5b..d5099721ed 100644 --- a/crates/agent/src/agent_configuration.rs +++ b/crates/agent/src/agent_configuration.rs @@ -1,4 +1,3 @@ -mod add_context_server_modal; mod configure_context_server_modal; mod manage_profiles_modal; mod tool_picker; @@ -9,22 +8,29 @@ use agent_settings::AgentSettings; use assistant_tool::{ToolSource, ToolWorkingSet}; use collections::HashMap; use context_server::ContextServerId; +use extension::ExtensionManifest; +use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, Animation, AnimationExt as _, AnyView, App, Entity, EventEmitter, FocusHandle, - Focusable, ScrollHandle, Subscription, Transformation, percentage, + Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle, + Focusable, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, }; +use language::LanguageRegistry; use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry}; -use project::context_server_store::{ContextServerStatus, ContextServerStore}; +use notifications::status_toast::{StatusToast, ToastIcon}; +use project::{ + context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, + project_settings::ProjectSettings, +}; use settings::{Settings, update_settings_file}; use ui::{ - Disclosure, ElevationIndex, Indicator, Scrollbar, ScrollbarState, Switch, SwitchColor, Tooltip, - prelude::*, + ContextMenu, Disclosure, ElevationIndex, Indicator, PopoverMenu, Scrollbar, ScrollbarState, + Switch, SwitchColor, Tooltip, prelude::*, }; use util::ResultExt as _; +use workspace::Workspace; use zed_actions::ExtensionCategoryFilter; -pub(crate) use add_context_server_modal::AddContextServerModal; pub(crate) use configure_context_server_modal::ConfigureContextServerModal; pub(crate) use manage_profiles_modal::ManageProfilesModal; @@ -32,6 +38,8 @@ use crate::AddContextServer; pub struct AgentConfiguration { fs: Arc, + language_registry: Arc, + workspace: WeakEntity, focus_handle: FocusHandle, configuration_views_by_provider: HashMap, context_server_store: Entity, @@ -48,6 +56,8 @@ impl AgentConfiguration { fs: Arc, context_server_store: Entity, tools: Entity, + language_registry: Arc, + workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -70,11 +80,16 @@ impl AgentConfiguration { }, ); + cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()) + .detach(); + let scroll_handle = ScrollHandle::new(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); let mut this = Self { fs, + language_registry, + workspace, focus_handle, configuration_views_by_provider: HashMap::default(), context_server_store, @@ -460,9 +475,22 @@ impl AgentConfiguration { .read(cx) .status_for_server(&context_server_id) .unwrap_or(ContextServerStatus::Stopped); + let server_configuration = self + .context_server_store + .read(cx) + .configuration_for_server(&context_server_id); let is_running = matches!(server_status, ContextServerStatus::Running); let item_id = SharedString::from(context_server_id.0.clone()); + let is_from_extension = server_configuration + .as_ref() + .map(|config| { + matches!( + config.as_ref(), + ContextServerConfiguration::Extension { .. } + ) + }) + .unwrap_or(false); let error = if let ContextServerStatus::Error(error) = server_status.clone() { Some(error) @@ -484,6 +512,18 @@ impl AgentConfiguration { let border_color = cx.theme().colors().border.opacity(0.6); + let (source_icon, source_tooltip) = if is_from_extension { + ( + IconName::ZedMcpExtension, + "This MCP server was installed from an extension.", + ) + } else { + ( + IconName::ZedMcpCustom, + "This custom MCP server was installed directly.", + ) + }; + let (status_indicator, tooltip_text) = match server_status { ContextServerStatus::Starting => ( Icon::new(IconName::LoadCircle) @@ -511,6 +551,105 @@ impl AgentConfiguration { ), }; + let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu") + .trigger_with_tooltip( + IconButton::new("context-server-config-menu", IconName::Settings) + .icon_color(Color::Muted) + .icon_size(IconSize::Small), + Tooltip::text("Open MCP server options"), + ) + .anchor(Corner::TopRight) + .menu({ + let fs = self.fs.clone(); + let context_server_id = context_server_id.clone(); + let language_registry = self.language_registry.clone(); + let context_server_store = self.context_server_store.clone(); + let workspace = self.workspace.clone(); + move |window, cx| { + Some(ContextMenu::build(window, cx, |menu, _window, _cx| { + menu.entry("Configure Server", None, { + let context_server_id = context_server_id.clone(); + let language_registry = language_registry.clone(); + let workspace = workspace.clone(); + move |window, cx| { + ConfigureContextServerModal::show_modal_for_existing_server( + context_server_id.clone(), + language_registry.clone(), + workspace.clone(), + window, + cx, + ) + .detach_and_log_err(cx); + } + }) + .separator() + .entry("Delete", None, { + let fs = fs.clone(); + let context_server_id = context_server_id.clone(); + let context_server_store = context_server_store.clone(); + let workspace = workspace.clone(); + move |_, cx| { + let is_provided_by_extension = context_server_store + .read(cx) + .configuration_for_server(&context_server_id) + .as_ref() + .map(|config| { + matches!( + config.as_ref(), + ContextServerConfiguration::Extension { .. } + ) + }) + .unwrap_or(false); + + let uninstall_extension_task = match ( + is_provided_by_extension, + resolve_extension_for_context_server(&context_server_id, cx), + ) { + (true, Some((id, manifest))) => { + if extension_only_provides_context_server(manifest.as_ref()) + { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.uninstall_extension(id, cx) + }) + } else { + workspace.update(cx, |workspace, cx| { + show_unable_to_uninstall_extension_with_context_server(workspace, context_server_id.clone(), cx); + }).log_err(); + Task::ready(Ok(())) + } + } + _ => Task::ready(Ok(())), + }; + + cx.spawn({ + let fs = fs.clone(); + let context_server_id = context_server_id.clone(); + async move |cx| { + uninstall_extension_task.await?; + cx.update(|cx| { + update_settings_file::( + fs.clone(), + cx, + { + let context_server_id = + context_server_id.clone(); + move |settings, _| { + settings + .context_servers + .remove(&context_server_id.0); + } + }, + ) + }) + } + }) + .detach_and_log_err(cx); + } + }) + })) + } + }); + v_flex() .id(item_id.clone()) .border_1() @@ -556,7 +695,19 @@ impl AgentConfiguration { .tooltip(Tooltip::text(tooltip_text)) .child(status_indicator), ) - .child(Label::new(item_id).ml_0p5().mr_1p5()) + .child(Label::new(item_id).ml_0p5()) + .child( + div() + .id("extension-source") + .mt_0p5() + .mx_1() + .tooltip(Tooltip::text(source_tooltip)) + .child( + Icon::new(source_icon) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) .when(is_running, |this| { this.child( Label::new(if tool_count == 1 { @@ -570,28 +721,37 @@ impl AgentConfiguration { }), ) .child( - Switch::new("context-server-switch", is_running.into()) - .color(SwitchColor::Accent) - .on_click({ - let context_server_manager = self.context_server_store.clone(); - let context_server_id = context_server_id.clone(); - move |state, _window, cx| match state { - ToggleState::Unselected | ToggleState::Indeterminate => { - context_server_manager.update(cx, |this, cx| { - this.stop_server(&context_server_id, cx).log_err(); - }); - } - ToggleState::Selected => { - context_server_manager.update(cx, |this, cx| { - if let Some(server) = - this.get_server(&context_server_id) - { - this.start_server(server, cx); + h_flex() + .gap_1() + .child(context_server_configuration_menu) + .child( + Switch::new("context-server-switch", is_running.into()) + .color(SwitchColor::Accent) + .on_click({ + let context_server_manager = + self.context_server_store.clone(); + let context_server_id = context_server_id.clone(); + + move |state, _window, cx| match state { + ToggleState::Unselected + | ToggleState::Indeterminate => { + context_server_manager.update(cx, |this, cx| { + this.stop_server(&context_server_id, cx) + .log_err(); + }); } - }) - } - } - }), + ToggleState::Selected => { + context_server_manager.update(cx, |this, cx| { + if let Some(server) = + this.get_server(&context_server_id) + { + this.start_server(server, cx); + } + }) + } + } + }), + ), ), ) .map(|parent| { @@ -701,3 +861,51 @@ impl Render for AgentConfiguration { ) } } + +fn extension_only_provides_context_server(manifest: &ExtensionManifest) -> bool { + manifest.context_servers.len() == 1 + && manifest.themes.is_empty() + && manifest.icon_themes.is_empty() + && manifest.languages.is_empty() + && manifest.grammars.is_empty() + && manifest.language_servers.is_empty() + && manifest.slash_commands.is_empty() + && manifest.indexed_docs_providers.is_empty() + && manifest.snippets.is_none() + && manifest.debug_locators.is_empty() +} + +pub(crate) fn resolve_extension_for_context_server( + id: &ContextServerId, + cx: &App, +) -> Option<(Arc, Arc)> { + ExtensionStore::global(cx) + .read(cx) + .installed_extensions() + .iter() + .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0)) + .map(|(id, entry)| (id.clone(), entry.manifest.clone())) +} + +// This notification appears when trying to delete +// an MCP server extension that not only provides +// the server, but other things, too, like language servers and more. +fn show_unable_to_uninstall_extension_with_context_server( + workspace: &mut Workspace, + id: ContextServerId, + cx: &mut App, +) { + let status_toast = StatusToast::new( + format!( + "Unable to uninstall the {} extension, as it provides more than just the MCP server.", + id.0 + ), + cx, + |this, _cx| { + this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning)) + .action("Dismiss", |_, _| {}) + }, + ); + + workspace.toggle_status_toast(status_toast, cx); +} diff --git a/crates/agent/src/agent_configuration/add_context_server_modal.rs b/crates/agent/src/agent_configuration/add_context_server_modal.rs deleted file mode 100644 index d9eff2a0b3..0000000000 --- a/crates/agent/src/agent_configuration/add_context_server_modal.rs +++ /dev/null @@ -1,195 +0,0 @@ -use context_server::ContextServerCommand; -use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity, prelude::*}; -use project::project_settings::{ContextServerSettings, ProjectSettings}; -use settings::update_settings_file; -use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*}; -use ui_input::SingleLineInput; -use workspace::{ModalView, Workspace}; - -use crate::AddContextServer; - -pub struct AddContextServerModal { - workspace: WeakEntity, - name_editor: Entity, - command_editor: Entity, -} - -impl AddContextServerModal { - pub fn register( - workspace: &mut Workspace, - _window: Option<&mut Window>, - _cx: &mut Context, - ) { - workspace.register_action(|workspace, _: &AddContextServer, window, cx| { - let workspace_handle = cx.entity().downgrade(); - workspace.toggle_modal(window, cx, |window, cx| { - Self::new(workspace_handle, window, cx) - }) - }); - } - - pub fn new( - workspace: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let name_editor = - cx.new(|cx| SingleLineInput::new(window, cx, "my-custom-server").label("Name")); - let command_editor = cx.new(|cx| { - SingleLineInput::new(window, cx, "Command").label("Command to run the MCP server") - }); - - Self { - name_editor, - command_editor, - workspace, - } - } - - fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context) { - let name = self - .name_editor - .read(cx) - .editor() - .read(cx) - .text(cx) - .trim() - .to_string(); - let command = self - .command_editor - .read(cx) - .editor() - .read(cx) - .text(cx) - .trim() - .to_string(); - - if name.is_empty() || command.is_empty() { - return; - } - - let mut command_parts = command.split(' ').map(|part| part.trim().to_string()); - let Some(path) = command_parts.next() else { - return; - }; - let args = command_parts.collect::>(); - - if let Some(workspace) = self.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - let fs = workspace.app_state().fs.clone(); - update_settings_file::(fs.clone(), cx, |settings, _| { - settings.context_servers.insert( - name.into(), - ContextServerSettings::Custom { - command: ContextServerCommand { - path, - args, - env: None, - }, - }, - ); - }); - }); - } - - cx.emit(DismissEvent); - } - - fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context) { - cx.emit(DismissEvent); - } -} - -impl ModalView for AddContextServerModal {} - -impl Focusable for AddContextServerModal { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.name_editor.focus_handle(cx).clone() - } -} - -impl EventEmitter for AddContextServerModal {} - -impl Render for AddContextServerModal { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let is_name_empty = self.name_editor.read(cx).is_empty(cx); - let is_command_empty = self.command_editor.read(cx).is_empty(cx); - - let focus_handle = self.focus_handle(cx); - - div() - .elevation_3(cx) - .w(rems(34.)) - .key_context("AddContextServerModal") - .on_action( - cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)), - ) - .on_action( - cx.listener(|this, _: &menu::Confirm, _window, cx| { - this.confirm(&menu::Confirm, cx) - }), - ) - .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); - })) - .on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent))) - .child( - Modal::new("add-context-server", None) - .header(ModalHeader::new().headline("Add MCP Server")) - .section( - Section::new().child( - v_flex() - .gap_2() - .child(self.name_editor.clone()) - .child(self.command_editor.clone()), - ), - ) - .footer( - ModalFooter::new().end_slot( - h_flex() - .gap_2() - .child( - Button::new("cancel", "Cancel") - .key_binding( - KeyBinding::for_action_in( - &menu::Cancel, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _event, _window, cx| { - this.cancel(&menu::Cancel, cx) - })), - ) - .child( - Button::new("add-server", "Add Server") - .disabled(is_name_empty || is_command_empty) - .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .map(|button| { - if is_name_empty { - button.tooltip(Tooltip::text("Name is required")) - } else if is_command_empty { - button.tooltip(Tooltip::text("Command is required")) - } else { - button - } - }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.confirm(&menu::Confirm, cx) - })), - ), - ), - ), - ) - } -} diff --git a/crates/agent/src/agent_configuration/configure_context_server_modal.rs b/crates/agent/src/agent_configuration/configure_context_server_modal.rs index 923bb07992..651c63406e 100644 --- a/crates/agent/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent/src/agent_configuration/configure_context_server_modal.rs @@ -3,215 +3,384 @@ use std::{ time::Duration, }; -use anyhow::Context as _; -use context_server::ContextServerId; +use anyhow::{Context as _, Result}; +use context_server::{ContextServerCommand, ContextServerId}; use editor::{Editor, EditorElement, EditorStyle}; use gpui::{ - Animation, AnimationExt, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, - TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, percentage, + Animation, AnimationExt as _, AsyncWindowContext, DismissEvent, Entity, EventEmitter, + FocusHandle, Focusable, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, + WeakEntity, percentage, prelude::*, }; use language::{Language, LanguageRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ - context_server_store::{ContextServerStatus, ContextServerStore}, + context_server_store::{ + ContextServerStatus, ContextServerStore, registry::ContextServerDescriptorRegistry, + }, project_settings::{ContextServerSettings, ProjectSettings}, + worktree_store::WorktreeStore, }; use settings::{Settings as _, update_settings_file}; use theme::ThemeSettings; use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*}; -use util::ResultExt; +use util::ResultExt as _; use workspace::{ModalView, Workspace}; -pub(crate) struct ConfigureContextServerModal { - workspace: WeakEntity, - focus_handle: FocusHandle, - context_servers_to_setup: Vec, - context_server_store: Entity, +use crate::AddContextServer; + +enum ConfigurationTarget { + New, + Existing { + id: ContextServerId, + command: ContextServerCommand, + }, + Extension { + id: ContextServerId, + repository_url: Option, + installation: Option, + }, } -enum Configuration { - NotAvailable, - Required(ConfigurationRequiredState), +enum ConfigurationSource { + New { + editor: Entity, + }, + Existing { + editor: Entity, + }, + Extension { + id: ContextServerId, + editor: Option>, + repository_url: Option, + installation_instructions: Option>, + settings_validator: Option, + }, } -struct ConfigurationRequiredState { - installation_instructions: Entity, - settings_validator: Option, - settings_editor: Entity, - last_error: Option, - waiting_for_context_server: bool, -} +impl ConfigurationSource { + fn has_configuration_options(&self) -> bool { + !matches!(self, ConfigurationSource::Extension { editor: None, .. }) + } -struct ContextServerSetup { - id: ContextServerId, - repository_url: Option, - configuration: Configuration, -} + fn is_new(&self) -> bool { + matches!(self, ConfigurationSource::New { .. }) + } -impl ConfigureContextServerModal { - pub fn new( - configurations: impl Iterator, - context_server_store: Entity, - jsonc_language: Option>, + fn from_target( + target: ConfigurationTarget, language_registry: Arc, - workspace: WeakEntity, + jsonc_language: Option>, window: &mut Window, - cx: &mut Context, + cx: &mut App, ) -> Self { - let context_servers_to_setup = configurations - .map(|config| match config { - crate::context_server_configuration::Configuration::NotAvailable( - context_server_id, - repository_url, - ) => ContextServerSetup { - id: context_server_id, - repository_url, - configuration: Configuration::NotAvailable, - }, - crate::context_server_configuration::Configuration::Required( - context_server_id, - repository_url, - config, - ) => { - let jsonc_language = jsonc_language.clone(); - let settings_validator = jsonschema::validator_for(&config.settings_schema) + fn create_editor( + json: String, + jsonc_language: Option>, + window: &mut Window, + cx: &mut App, + ) -> Entity { + cx.new(|cx| { + let mut editor = Editor::auto_height(4, 16, window, cx); + editor.set_text(json, window, cx); + editor.set_show_gutter(false, cx); + editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx); + if let Some(buffer) = editor.buffer().read(cx).as_singleton() { + buffer.update(cx, |buffer, cx| buffer.set_language(jsonc_language, cx)) + } + editor + }) + } + + match target { + ConfigurationTarget::New => ConfigurationSource::New { + editor: create_editor(context_server_input(None), jsonc_language, window, cx), + }, + ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing { + editor: create_editor( + context_server_input(Some((id, command))), + jsonc_language, + window, + cx, + ), + }, + ConfigurationTarget::Extension { + id, + repository_url, + installation, + } => { + let settings_validator = installation.as_ref().and_then(|installation| { + jsonschema::validator_for(&installation.settings_schema) .context("Failed to load JSON schema for context server settings") - .log_err(); - let state = ConfigurationRequiredState { - installation_instructions: cx.new(|cx| { - Markdown::new( - config.installation_instructions.clone().into(), - Some(language_registry.clone()), - None, - cx, - ) - }), - settings_validator, - settings_editor: cx.new(|cx| { - let mut editor = Editor::auto_height(1, 16, window, cx); - editor.set_text(config.default_settings.trim(), window, cx); - editor.set_show_gutter(false, cx); - editor.set_soft_wrap_mode( - language::language_settings::SoftWrap::None, - cx, - ); - if let Some(buffer) = editor.buffer().read(cx).as_singleton() { - buffer.update(cx, |buffer, cx| { - buffer.set_language(jsonc_language, cx) - }) - } - editor - }), - waiting_for_context_server: false, - last_error: None, - }; - ContextServerSetup { - id: context_server_id, - repository_url, - configuration: Configuration::Required(state), + .log_err() + }); + let installation_instructions = installation.as_ref().map(|installation| { + cx.new(|cx| { + Markdown::new( + installation.installation_instructions.clone().into(), + Some(language_registry.clone()), + None, + cx, + ) + }) + }); + ConfigurationSource::Extension { + id, + repository_url, + installation_instructions, + settings_validator, + editor: installation.map(|installation| { + create_editor(installation.default_settings, jsonc_language, window, cx) + }), + } + } + } + } + + fn output(&self, cx: &mut App) -> Result<(ContextServerId, ContextServerSettings)> { + match self { + ConfigurationSource::New { editor } | ConfigurationSource::Existing { editor } => { + parse_input(&editor.read(cx).text(cx)) + .map(|(id, command)| (id, ContextServerSettings::Custom { command })) + } + ConfigurationSource::Extension { + id, + editor, + settings_validator, + .. + } => { + let text = editor + .as_ref() + .context("No output available")? + .read(cx) + .text(cx); + let settings = serde_json_lenient::from_str::(&text)?; + if let Some(settings_validator) = settings_validator { + if let Err(error) = settings_validator.validate(&settings) { + return Err(anyhow::anyhow!(error.to_string())); } } - }) - .collect::>(); - - Self { - workspace, - focus_handle: cx.focus_handle(), - context_servers_to_setup, - context_server_store, + Ok((id.clone(), ContextServerSettings::Extension { settings })) + } } } } -impl ConfigureContextServerModal { - pub fn confirm(&mut self, cx: &mut Context) { - if self.context_servers_to_setup.is_empty() { - self.dismiss(cx); - return; +fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)>) -> String { + let (name, path, args, env) = match existing { + Some((id, cmd)) => { + let args = serde_json::to_string(&cmd.args).unwrap(); + let env = serde_json::to_string(&cmd.env.unwrap_or_default()).unwrap(); + (id.0.to_string(), cmd.path, args, env) } + None => ( + "some-mcp-server".to_string(), + "".to_string(), + "[]".to_string(), + "{}".to_string(), + ), + }; + format!( + r#"{{ + /// The name of your MCP server + "{name}": {{ + "command": {{ + /// The path to the executable + "path": "{path}", + /// The arguments to pass to the executable + "args": {args}, + /// The environment variables to set for the executable + "env": {env} + }} + }} +}}"# + ) +} + +fn resolve_context_server_extension( + id: ContextServerId, + worktree_store: Entity, + cx: &mut App, +) -> Task> { + let registry = ContextServerDescriptorRegistry::default_global(cx).read(cx); + + let Some(descriptor) = registry.context_server_descriptor(&id.0) else { + return Task::ready(None); + }; + + let extension = crate::agent_configuration::resolve_extension_for_context_server(&id, cx); + cx.spawn(async move |cx| { + let installation = descriptor + .configuration(worktree_store, cx) + .await + .context("Failed to resolve context server configuration") + .log_err() + .flatten(); + + Some(ConfigurationTarget::Extension { + id, + repository_url: extension + .and_then(|(_, manifest)| manifest.repository.clone().map(SharedString::from)), + installation, + }) + }) +} + +enum State { + Idle, + Waiting, + Error(SharedString), +} + +pub struct ConfigureContextServerModal { + context_server_store: Entity, + workspace: WeakEntity, + source: ConfigurationSource, + state: State, +} + +impl ConfigureContextServerModal { + pub fn register( + workspace: &mut Workspace, + language_registry: Arc, + _window: Option<&mut Window>, + _cx: &mut Context, + ) { + workspace.register_action({ + let language_registry = language_registry.clone(); + move |_workspace, _: &AddContextServer, window, cx| { + let workspace_handle = cx.weak_entity(); + let language_registry = language_registry.clone(); + window + .spawn(cx, async move |cx| { + Self::show_modal( + ConfigurationTarget::New, + language_registry, + workspace_handle, + cx, + ) + .await + }) + .detach_and_log_err(cx); + } + }); + } + + pub fn show_modal_for_existing_server( + server_id: ContextServerId, + language_registry: Arc, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Task> { + let Some(settings) = ProjectSettings::get_global(cx) + .context_servers + .get(&server_id.0) + .cloned() + .or_else(|| { + ContextServerDescriptorRegistry::default_global(cx) + .read(cx) + .context_server_descriptor(&server_id.0) + .map(|_| ContextServerSettings::Extension { + settings: serde_json::json!({}), + }) + }) + else { + return Task::ready(Err(anyhow::anyhow!("Context server not found"))); + }; + + window.spawn(cx, async move |cx| { + let target = match settings { + ContextServerSettings::Custom { command } => Some(ConfigurationTarget::Existing { + id: server_id, + command, + }), + ContextServerSettings::Extension { .. } => { + match workspace + .update(cx, |workspace, cx| { + resolve_context_server_extension( + server_id, + workspace.project().read(cx).worktree_store(), + cx, + ) + }) + .ok() + { + Some(task) => task.await, + None => None, + } + } + }; + + match target { + Some(target) => Self::show_modal(target, language_registry, workspace, cx).await, + None => Err(anyhow::anyhow!("Failed to resolve context server")), + } + }) + } + + fn show_modal( + target: ConfigurationTarget, + language_registry: Arc, + workspace: WeakEntity, + cx: &mut AsyncWindowContext, + ) -> Task> { + cx.spawn(async move |cx| { + let jsonc_language = language_registry.language_for_name("jsonc").await.ok(); + workspace.update_in(cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let context_server_store = workspace.project().read(cx).context_server_store(); + workspace.toggle_modal(window, cx, |window, cx| Self { + context_server_store, + workspace: workspace_handle, + state: State::Idle, + source: ConfigurationSource::from_target( + target, + language_registry, + jsonc_language, + window, + cx, + ), + }) + }) + }) + } + + fn set_error(&mut self, err: impl Into, cx: &mut Context) { + self.state = State::Error(err.into()); + cx.notify(); + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context) { + self.state = State::Idle; let Some(workspace) = self.workspace.upgrade() else { return; }; - let id = self.context_servers_to_setup[0].id.clone(); - let configuration = match &mut self.context_servers_to_setup[0].configuration { - Configuration::NotAvailable => { - self.context_servers_to_setup.remove(0); - if self.context_servers_to_setup.is_empty() { - self.dismiss(cx); - } - return; - } - Configuration::Required(state) => state, - }; - - configuration.last_error.take(); - if configuration.waiting_for_context_server { - return; - } - - let settings_value = match serde_json_lenient::from_str::( - &configuration.settings_editor.read(cx).text(cx), - ) { - Ok(value) => value, + let (id, settings) = match self.source.output(cx) { + Ok(val) => val, Err(error) => { - configuration.last_error = Some(error.to_string().into()); - cx.notify(); + self.set_error(error.to_string(), cx); return; } }; - if let Some(validator) = configuration.settings_validator.as_ref() { - if let Err(error) = validator.validate(&settings_value) { - configuration.last_error = Some(error.to_string().into()); - cx.notify(); - return; - } - } - let id = id.clone(); - - let settings_changed = ProjectSettings::get_global(cx) - .context_servers - .get(&id.0) - .map_or(true, |settings| match settings { - ContextServerSettings::Custom { .. } => false, - ContextServerSettings::Extension { settings } => settings != &settings_value, - }); - - let is_running = self.context_server_store.read(cx).status_for_server(&id) - == Some(ContextServerStatus::Running); - - if !settings_changed && is_running { - self.complete_setup(id, cx); - return; - } - - configuration.waiting_for_context_server = true; - - let task = wait_for_context_server(&self.context_server_store, id.clone(), cx); + self.state = State::Waiting; + let wait_for_context_server_task = + wait_for_context_server(&self.context_server_store, id.clone(), cx); cx.spawn({ let id = id.clone(); async move |this, cx| { - let result = task.await; + let result = wait_for_context_server_task.await; this.update(cx, |this, cx| match result { Ok(_) => { - this.complete_setup(id, cx); + this.state = State::Idle; + this.show_configured_context_server_toast(id, cx); + cx.emit(DismissEvent); } Err(err) => { - if let Some(setup) = this.context_servers_to_setup.get_mut(0) { - match &mut setup.configuration { - Configuration::NotAvailable => {} - Configuration::Required(state) => { - state.last_error = Some(err.into()); - state.waiting_for_context_server = false; - } - } - } else { - this.dismiss(cx); - } - cx.notify(); + this.set_error(err, cx); } }) } @@ -219,32 +388,24 @@ impl ConfigureContextServerModal { .detach(); // When we write the settings to the file, the context server will be restarted. - update_settings_file::(workspace.read(cx).app_state().fs.clone(), cx, { - let id = id.clone(); - |settings, _| { - settings.context_servers.insert( - id.0, - ContextServerSettings::Extension { - settings: settings_value, - }, - ); - } + workspace.update(cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + update_settings_file::(fs.clone(), cx, |project_settings, _| { + project_settings.context_servers.insert(id.0, settings); + }); }); } - fn complete_setup(&mut self, id: ContextServerId, cx: &mut Context) { - self.context_servers_to_setup.remove(0); - cx.notify(); - - if !self.context_servers_to_setup.is_empty() { - return; - } + fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context) { + cx.emit(DismissEvent); + } + fn show_configured_context_server_toast(&self, id: ContextServerId, cx: &mut App) { self.workspace .update(cx, { |workspace, cx| { let status_toast = StatusToast::new( - format!("{} configured successfully.", id), + format!("{} configured successfully.", id.0), cx, |this, _cx| { this.icon(ToastIcon::new(IconName::Hammer).color(Color::Muted)) @@ -256,12 +417,264 @@ impl ConfigureContextServerModal { } }) .log_err(); + } +} - self.dismiss(cx); +fn parse_input(text: &str) -> Result<(ContextServerId, ContextServerCommand)> { + let value: serde_json::Value = serde_json_lenient::from_str(text)?; + let object = value.as_object().context("Expected object")?; + anyhow::ensure!(object.len() == 1, "Expected exactly one key-value pair"); + let (context_server_name, value) = object.into_iter().next().unwrap(); + let command = value.get("command").context("Expected command")?; + let command: ContextServerCommand = serde_json::from_value(command.clone())?; + Ok((ContextServerId(context_server_name.clone().into()), command)) +} + +impl ModalView for ConfigureContextServerModal {} + +impl Focusable for ConfigureContextServerModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + match &self.source { + ConfigurationSource::New { editor } => editor.focus_handle(cx), + ConfigurationSource::Existing { editor, .. } => editor.focus_handle(cx), + ConfigurationSource::Extension { editor, .. } => editor + .as_ref() + .map(|editor| editor.focus_handle(cx)) + .unwrap_or_else(|| cx.focus_handle()), + } + } +} + +impl EventEmitter for ConfigureContextServerModal {} + +impl ConfigureContextServerModal { + fn render_modal_header(&self) -> ModalHeader { + let text: SharedString = match &self.source { + ConfigurationSource::New { .. } => "Add MCP Server".into(), + ConfigurationSource::Existing { .. } => "Configure MCP Server".into(), + ConfigurationSource::Extension { id, .. } => format!("Configure {}", id.0).into(), + }; + ModalHeader::new().headline(text) } - fn dismiss(&self, cx: &mut Context) { - cx.emit(DismissEvent); + fn render_modal_description(&self, window: &mut Window, cx: &mut Context) -> AnyElement { + const MODAL_DESCRIPTION: &'static str = "Visit the MCP server configuration docs to find all necessary arguments and environment variables."; + + if let ConfigurationSource::Extension { + installation_instructions: Some(installation_instructions), + .. + } = &self.source + { + div() + .pb_2() + .text_sm() + .child(MarkdownElement::new( + installation_instructions.clone(), + default_markdown_style(window, cx), + )) + .into_any_element() + } else { + Label::new(MODAL_DESCRIPTION) + .color(Color::Muted) + .into_any_element() + } + } + + fn render_modal_content(&self, cx: &App) -> AnyElement { + let editor = match &self.source { + ConfigurationSource::New { editor } => editor, + ConfigurationSource::Existing { editor } => editor, + ConfigurationSource::Extension { editor, .. } => { + let Some(editor) = editor else { + return Label::new( + "No configuration options available for this context server. Visit the Repository for any further instructions.", + ) + .color(Color::Muted).into_any_element(); + }; + editor + } + }; + + div() + .p_2() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().editor_background) + .child({ + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }; + EditorElement::new( + editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + syntax: cx.theme().syntax().clone(), + ..Default::default() + }, + ) + }) + .into_any_element() + } + + fn render_modal_footer(&self, window: &mut Window, cx: &mut Context) -> ModalFooter { + let focus_handle = self.focus_handle(cx); + let is_connecting = matches!(self.state, State::Waiting); + + ModalFooter::new() + .start_slot::