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 <bennetbo@gmx.de>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
Danilo Leal 2025-06-18 19:52:40 -03:00 committed by GitHub
parent 526faf287d
commit 804b91aa8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 926 additions and 728 deletions

View file

@ -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

View file

@ -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();
}

View file

@ -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<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
context_server_store: Entity<ContextServerStore>,
@ -48,6 +56,8 @@ impl AgentConfiguration {
fs: Arc<dyn Fs>,
context_server_store: Entity<ContextServerStore>,
tools: Entity<ToolWorkingSet>,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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::<ProjectSettings>(
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<str>, Arc<ExtensionManifest>)> {
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);
}

View file

@ -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<Workspace>,
name_editor: Entity<SingleLineInput>,
command_editor: Entity<SingleLineInput>,
}
impl AddContextServerModal {
pub fn register(
workspace: &mut Workspace,
_window: Option<&mut Window>,
_cx: &mut Context<Workspace>,
) {
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<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
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::<Vec<_>>();
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
let fs = workspace.app_state().fs.clone();
update_settings_file::<ProjectSettings>(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<Self>) {
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<DismissEvent> for AddContextServerModal {}
impl Render for AddContextServerModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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)
})),
),
),
),
)
}
}

View file

@ -1184,8 +1184,17 @@ impl AgentPanel {
let fs = self.fs.clone();
self.set_active_view(ActiveView::Configuration, window, cx);
self.configuration =
Some(cx.new(|cx| AgentConfiguration::new(fs, context_server_store, tools, window, cx)));
self.configuration = Some(cx.new(|cx| {
AgentConfiguration::new(
fs,
context_server_store,
tools,
self.language_registry.clone(),
self.workspace.clone(),
window,
cx,
)
}));
if let Some(configuration) = self.configuration.as_ref() {
self.configuration_subscription = Some(cx.subscribe_in(

View file

@ -1,15 +1,11 @@
use std::sync::Arc;
use anyhow::Context as _;
use context_server::ContextServerId;
use extension::{ContextServerConfiguration, ExtensionManifest};
use extension::ExtensionManifest;
use fs::Fs;
use gpui::Task;
use gpui::WeakEntity;
use language::LanguageRegistry;
use project::{
context_server_store::registry::ContextServerDescriptorRegistry,
project_settings::ProjectSettings,
};
use project::project_settings::ProjectSettings;
use settings::update_settings_file;
use ui::prelude::*;
use util::ResultExt;
@ -27,12 +23,12 @@ pub(crate) fn init(language_registry: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, cx
cx.subscribe_in(extension_events, window, {
let language_registry = language_registry.clone();
let fs = fs.clone();
move |workspace, _, event, window, cx| match event {
move |_, _, event, window, cx| match event {
extension::Event::ExtensionInstalled(manifest) => {
show_configure_mcp_modal(
language_registry.clone(),
manifest,
workspace,
cx.weak_entity(),
window,
cx,
);
@ -49,7 +45,7 @@ pub(crate) fn init(language_registry: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, cx
show_configure_mcp_modal(
language_registry.clone(),
manifest,
workspace,
cx.weak_entity(),
window,
cx,
);
@ -80,19 +76,10 @@ fn remove_context_server_settings(
});
}
pub enum Configuration {
NotAvailable(ContextServerId, Option<SharedString>),
Required(
ContextServerId,
Option<SharedString>,
ContextServerConfiguration,
),
}
fn show_configure_mcp_modal(
language_registry: Arc<LanguageRegistry>,
manifest: &Arc<ExtensionManifest>,
workspace: &mut Workspace,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<'_, Workspace>,
) {
@ -100,70 +87,30 @@ fn show_configure_mcp_modal(
return;
}
let context_server_store = workspace.project().read(cx).context_server_store();
let repository: Option<SharedString> = manifest.repository.as_ref().map(|s| s.clone().into());
let ids = manifest.context_servers.keys().cloned().collect::<Vec<_>>();
if ids.is_empty() {
return;
}
let registry = ContextServerDescriptorRegistry::default_global(cx).read(cx);
let worktree_store = workspace.project().read(cx).worktree_store();
let configuration_tasks = manifest
.context_servers
.keys()
.cloned()
.map({
|key| {
let Some(descriptor) = registry.context_server_descriptor(&key) else {
return Task::ready(Configuration::NotAvailable(
ContextServerId(key),
repository.clone(),
));
window
.spawn(cx, async move |cx| {
for id in ids {
let Some(task) = cx
.update(|window, cx| {
ConfigureContextServerModal::show_modal_for_existing_server(
ContextServerId(id.clone()),
language_registry.clone(),
workspace.clone(),
window,
cx,
)
})
.ok()
else {
continue;
};
cx.spawn({
let repository_url = repository.clone();
let worktree_store = worktree_store.clone();
async move |_, cx| {
let configuration = descriptor
.configuration(worktree_store.clone(), &cx)
.await
.context("Failed to resolve context server configuration")
.log_err()
.flatten();
match configuration {
Some(config) => Configuration::Required(
ContextServerId(key),
repository_url,
config,
),
None => {
Configuration::NotAvailable(ContextServerId(key), repository_url)
}
}
}
})
task.await.log_err();
}
})
.collect::<Vec<_>>();
let jsonc_language = language_registry.language_for_name("jsonc");
cx.spawn_in(window, async move |this, cx| {
let configurations = futures::future::join_all(configuration_tasks).await;
let jsonc_language = jsonc_language.await.ok();
this.update_in(cx, |this, window, cx| {
let workspace = cx.entity().downgrade();
this.toggle_modal(window, cx, |window, cx| {
ConfigureContextServerModal::new(
configurations.into_iter(),
context_server_store,
jsonc_language,
language_registry,
workspace,
window,
cx,
)
});
})
})
.detach();
.detach();
}