agent: Handle context servers that do not provide a configuration in MCP setup dialog (#30023)

<img width="674" alt="image"
src="https://github.com/user-attachments/assets/0ccb89e2-1dc1-4caf-88a7-49159f43979f"
/>
<img width="675" alt="image"
src="https://github.com/user-attachments/assets/790e5d45-905e-45da-affa-04ddd1d33c65"
/>

Release Notes:

- N/A
This commit is contained in:
Bennet Bo Fenner 2025-05-06 19:18:49 +02:00 committed by Joseph T. Lyons
parent 80a85a31ab
commit 121e3b5dfd
4 changed files with 279 additions and 149 deletions

View File

@ -147,47 +147,50 @@ impl Render for AddContextServerModal {
), ),
) )
.footer( .footer(
ModalFooter::new() ModalFooter::new().end_slot(
.start_slot( h_flex()
Button::new("cancel", "Cancel") .gap_2()
.key_binding( .child(
KeyBinding::for_action_in( Button::new("cancel", "Cancel")
&menu::Cancel, .key_binding(
&focus_handle, KeyBinding::for_action_in(
window, &menu::Cancel,
cx, &focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
) )
.map(|kb| kb.size(rems_from_px(12.))), .on_click(cx.listener(|this, _event, _window, cx| {
) this.cancel(&menu::Cancel, cx)
.on_click(cx.listener(|this, _event, _window, cx| { })),
this.cancel(&menu::Cancel, cx) )
})), .child(
) Button::new("add-server", "Add Server")
.end_slot( .disabled(is_name_empty || is_command_empty)
Button::new("add-server", "Add Server") .key_binding(
.disabled(is_name_empty || is_command_empty) KeyBinding::for_action_in(
.key_binding( &menu::Confirm,
KeyBinding::for_action_in( &focus_handle,
&menu::Confirm, window,
&focus_handle, cx,
window, )
cx, .map(|kb| kb.size(rems_from_px(12.))),
) )
.map(|kb| kb.size(rems_from_px(12.))), .map(|button| {
) if is_name_empty {
.map(|button| { button.tooltip(Tooltip::text("Name is required"))
if is_name_empty { } else if is_command_empty {
button.tooltip(Tooltip::text("Name is required")) button.tooltip(Tooltip::text("Command is required"))
} else if is_command_empty { } else {
button.tooltip(Tooltip::text("Command is required")) button
} else { }
button })
} .on_click(cx.listener(|this, _event, _window, cx| {
}) this.confirm(&menu::Confirm, cx)
.on_click(cx.listener(|this, _event, _window, cx| { })),
this.confirm(&menu::Confirm, cx) ),
})), ),
),
), ),
) )
} }

View File

@ -19,18 +19,24 @@ use project::{
}; };
use settings::{Settings as _, update_settings_file}; use settings::{Settings as _, update_settings_file};
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, prelude::*}; use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*};
use util::ResultExt; use util::ResultExt;
use workspace::{ModalView, Workspace}; use workspace::{ModalView, Workspace};
pub(crate) struct ConfigureContextServerModal { pub(crate) struct ConfigureContextServerModal {
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
context_servers_to_setup: Vec<ConfigureContextServer>, focus_handle: FocusHandle,
context_servers_to_setup: Vec<ContextServerSetup>,
context_server_store: Entity<ContextServerStore>, context_server_store: Entity<ContextServerStore>,
} }
struct ConfigureContextServer { #[allow(clippy::large_enum_variant)]
id: ContextServerId, enum Configuration {
NotAvailable,
Required(ConfigurationRequiredState),
}
struct ConfigurationRequiredState {
installation_instructions: Entity<markdown::Markdown>, installation_instructions: Entity<markdown::Markdown>,
settings_validator: Option<jsonschema::Validator>, settings_validator: Option<jsonschema::Validator>,
settings_editor: Entity<Editor>, settings_editor: Entity<Editor>,
@ -38,64 +44,91 @@ struct ConfigureContextServer {
waiting_for_context_server: bool, waiting_for_context_server: bool,
} }
struct ContextServerSetup {
id: ContextServerId,
repository_url: Option<SharedString>,
configuration: Configuration,
}
impl ConfigureContextServerModal { impl ConfigureContextServerModal {
pub fn new( pub fn new(
configurations: impl Iterator<Item = (ContextServerId, extension::ContextServerConfiguration)>, configurations: impl Iterator<Item = crate::context_server_configuration::Configuration>,
context_server_store: Entity<ContextServerStore>, context_server_store: Entity<ContextServerStore>,
jsonc_language: Option<Arc<Language>>, jsonc_language: Option<Arc<Language>>,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut Context<Self>,
) -> Option<Self> { ) -> Self {
let context_servers_to_setup = configurations let context_servers_to_setup = configurations
.map(|(id, manifest)| { .map(|config| match config {
let jsonc_language = jsonc_language.clone(); crate::context_server_configuration::Configuration::NotAvailable(
let settings_validator = jsonschema::validator_for(&manifest.settings_schema) context_server_id,
.context("Failed to load JSON schema for context server settings") repository_url,
.log_err(); ) => ContextServerSetup {
ConfigureContextServer { id: context_server_id,
id: id.clone(), repository_url,
installation_instructions: cx.new(|cx| { configuration: Configuration::NotAvailable,
Markdown::new( },
manifest.installation_instructions.clone().into(), crate::context_server_configuration::Configuration::Required(
Some(language_registry.clone()), context_server_id,
None, repository_url,
cx, config,
) ) => {
}), let jsonc_language = jsonc_language.clone();
settings_validator, let settings_validator = jsonschema::validator_for(&config.settings_schema)
settings_editor: cx.new(|cx| { .context("Failed to load JSON schema for context server settings")
let mut editor = Editor::auto_height(16, window, cx); .log_err();
editor.set_text(manifest.default_settings.trim(), window, cx); let state = ConfigurationRequiredState {
editor.set_show_gutter(false, cx); installation_instructions: cx.new(|cx| {
editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx); Markdown::new(
if let Some(buffer) = editor.buffer().read(cx).as_singleton() { config.installation_instructions.clone().into(),
buffer.update(cx, |buffer, cx| buffer.set_language(jsonc_language, cx)) Some(language_registry.clone()),
} None,
editor cx,
}), )
waiting_for_context_server: false, }),
last_error: None, settings_validator,
settings_editor: cx.new(|cx| {
let mut editor = Editor::auto_height(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),
}
} }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if context_servers_to_setup.is_empty() { Self {
return None;
}
Some(Self {
workspace, workspace,
focus_handle: cx.focus_handle(),
context_servers_to_setup, context_servers_to_setup,
context_server_store, context_server_store,
}) }
} }
} }
impl ConfigureContextServerModal { impl ConfigureContextServerModal {
pub fn confirm(&mut self, cx: &mut Context<Self>) { pub fn confirm(&mut self, cx: &mut Context<Self>) {
if self.context_servers_to_setup.is_empty() { if self.context_servers_to_setup.is_empty() {
self.dismiss(cx);
return; return;
} }
@ -103,7 +136,18 @@ impl ConfigureContextServerModal {
return; return;
}; };
let configuration = &mut self.context_servers_to_setup[0]; 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(); configuration.last_error.take();
if configuration.waiting_for_context_server { if configuration.waiting_for_context_server {
return; return;
@ -127,7 +171,7 @@ impl ConfigureContextServerModal {
return; return;
} }
} }
let id = configuration.id.clone(); let id = id.clone();
let settings_changed = ProjectSettings::get_global(cx) let settings_changed = ProjectSettings::get_global(cx)
.context_servers .context_servers
@ -156,9 +200,14 @@ impl ConfigureContextServerModal {
this.complete_setup(id, cx); this.complete_setup(id, cx);
} }
Err(err) => { Err(err) => {
if let Some(configuration) = this.context_servers_to_setup.get_mut(0) { if let Some(setup) = this.context_servers_to_setup.get_mut(0) {
configuration.last_error = Some(err.into()); match &mut setup.configuration {
configuration.waiting_for_context_server = false; Configuration::NotAvailable => {}
Configuration::Required(state) => {
state.last_error = Some(err.into());
state.waiting_for_context_server = false;
}
}
} else { } else {
this.dismiss(cx); this.dismiss(cx);
} }
@ -267,8 +316,8 @@ fn wait_for_context_server(
impl Render for ConfigureContextServerModal { impl Render for ConfigureContextServerModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(configuration) = self.context_servers_to_setup.first() else { let Some(setup) = self.context_servers_to_setup.first() else {
return div().child("No context servers to setup"); return div().into_any_element();
}; };
let focus_handle = self.focus_handle(cx); let focus_handle = self.focus_handle(cx);
@ -277,6 +326,7 @@ impl Render for ConfigureContextServerModal {
.elevation_3(cx) .elevation_3(cx)
.w(rems(42.)) .w(rems(42.))
.key_context("ConfigureContextServerModal") .key_context("ConfigureContextServerModal")
.track_focus(&focus_handle)
.on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| this.confirm(cx))) .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| this.confirm(cx)))
.on_action(cx.listener(|this, _: &menu::Cancel, _window, cx| this.dismiss(cx))) .on_action(cx.listener(|this, _: &menu::Cancel, _window, cx| this.dismiss(cx)))
.capture_any_mouse_down(cx.listener(|this, _, window, cx| { .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
@ -284,9 +334,15 @@ impl Render for ConfigureContextServerModal {
})) }))
.child( .child(
Modal::new("configure-context-server", None) Modal::new("configure-context-server", None)
.header(ModalHeader::new().headline(format!("Configure {}", configuration.id))) .header(ModalHeader::new().headline(format!("Configure {}", setup.id)))
.section( .section(match &setup.configuration {
Section::new() Configuration::NotAvailable => Section::new().child(
Label::new(
"No configuration options available for this context server. Visit the Repository for any further instructions.",
)
.color(Color::Muted),
),
Configuration::Required(configuration) => Section::new()
.child(div().pb_2().text_sm().child(MarkdownElement::new( .child(div().pb_2().text_sm().child(MarkdownElement::new(
configuration.installation_instructions.clone(), configuration.installation_instructions.clone(),
default_markdown_style(window, cx), default_markdown_style(window, cx),
@ -370,45 +426,84 @@ impl Render for ConfigureContextServerModal {
), ),
) )
}), }),
) })
.footer( .footer(
ModalFooter::new().end_slot( ModalFooter::new()
h_flex() .when_some(setup.repository_url.clone(), |this, repository_url| {
.gap_1() this.start_slot(
.child( h_flex().w_full().child(
Button::new("cancel", "Cancel") Button::new("open-repository", "Open Repository")
.key_binding( .icon(IconName::ArrowUpRight)
KeyBinding::for_action_in( .icon_color(Color::Muted)
&menu::Cancel, .icon_size(IconSize::XSmall)
&focus_handle, .tooltip({
window, let repository_url = repository_url.clone();
cx, move |window, cx| {
) Tooltip::with_meta(
.map(|kb| kb.size(rems_from_px(12.))), "Open Repository",
) None,
.on_click(cx.listener(|this, _event, _window, cx| { repository_url.clone(),
this.dismiss(cx) window,
})), cx,
)
}
})
.on_click(move |_, _, cx| cx.open_url(&repository_url)),
),
) )
.child( })
Button::new("configure-server", "Configure MCP") .end_slot(match &setup.configuration {
.disabled(configuration.waiting_for_context_server) Configuration::NotAvailable => Button::new("dismiss", "Dismiss")
.key_binding( .key_binding(
KeyBinding::for_action_in( KeyBinding::for_action_in(
&menu::Confirm, &menu::Cancel,
&focus_handle, &focus_handle,
window, window,
cx, cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
) )
.on_click(cx.listener(|this, _event, _window, cx| { .map(|kb| kb.size(rems_from_px(12.))),
this.confirm(cx) )
})), .on_click(
), cx.listener(|this, _event, _window, cx| this.dismiss(cx)),
), )
.into_any_element(),
Configuration::Required(state) => 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.dismiss(cx)
})),
)
.child(
Button::new("configure-server", "Configure MCP")
.disabled(state.waiting_for_context_server)
.key_binding(
KeyBinding::for_action_in(
&menu::Confirm,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(|this, _event, _window, cx| {
this.confirm(cx)
})),
)
.into_any_element(),
}),
), ),
) ).into_any_element()
} }
} }
@ -446,9 +541,14 @@ impl EventEmitter<DismissEvent> for ConfigureContextServerModal {}
impl Focusable for ConfigureContextServerModal { impl Focusable for ConfigureContextServerModal {
fn focus_handle(&self, cx: &App) -> FocusHandle { fn focus_handle(&self, cx: &App) -> FocusHandle {
if let Some(current) = self.context_servers_to_setup.first() { if let Some(current) = self.context_servers_to_setup.first() {
current.settings_editor.read(cx).focus_handle(cx) match &current.configuration {
Configuration::NotAvailable => self.focus_handle.clone(),
Configuration::Required(configuration) => {
configuration.settings_editor.read(cx).focus_handle(cx)
}
}
} else { } else {
cx.focus_handle() self.focus_handle.clone()
} }
} }
} }

View File

@ -2,7 +2,8 @@ use std::sync::Arc;
use anyhow::Context as _; use anyhow::Context as _;
use context_server::ContextServerId; use context_server::ContextServerId;
use extension::ExtensionManifest; use extension::{ContextServerConfiguration, ExtensionManifest};
use gpui::Task;
use language::LanguageRegistry; use language::LanguageRegistry;
use project::context_server_store::registry::ContextServerDescriptorRegistry; use project::context_server_store::registry::ContextServerDescriptorRegistry;
use ui::prelude::*; use ui::prelude::*;
@ -54,6 +55,15 @@ pub(crate) fn init(language_registry: Arc<LanguageRegistry>, cx: &mut App) {
.detach(); .detach();
} }
pub enum Configuration {
NotAvailable(ContextServerId, Option<SharedString>),
Required(
ContextServerId,
Option<SharedString>,
ContextServerConfiguration,
),
}
fn show_configure_mcp_modal( fn show_configure_mcp_modal(
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
manifest: &Arc<ExtensionManifest>, manifest: &Arc<ExtensionManifest>,
@ -62,6 +72,7 @@ fn show_configure_mcp_modal(
cx: &mut Context<'_, Workspace>, cx: &mut Context<'_, Workspace>,
) { ) {
let context_server_store = workspace.project().read(cx).context_server_store(); 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 registry = ContextServerDescriptorRegistry::default_global(cx).read(cx); let registry = ContextServerDescriptorRegistry::default_global(cx).read(cx);
let worktree_store = workspace.project().read(cx).worktree_store(); let worktree_store = workspace.project().read(cx).worktree_store();
@ -69,21 +80,37 @@ fn show_configure_mcp_modal(
.context_servers .context_servers
.keys() .keys()
.cloned() .cloned()
.filter_map({ .map({
|key| { |key| {
let descriptor = registry.context_server_descriptor(&key)?; let Some(descriptor) = registry.context_server_descriptor(&key) else {
Some(cx.spawn({ return Task::ready(Configuration::NotAvailable(
ContextServerId(key),
repository.clone(),
));
};
cx.spawn({
let repository_url = repository.clone();
let worktree_store = worktree_store.clone(); let worktree_store = worktree_store.clone();
async move |_, cx| { async move |_, cx| {
descriptor let configuration = descriptor
.configuration(worktree_store.clone(), &cx) .configuration(worktree_store.clone(), &cx)
.await .await
.context("Failed to resolve context server configuration") .context("Failed to resolve context server configuration")
.log_err() .log_err()
.flatten() .flatten();
.map(|config| (ContextServerId(key), config))
match configuration {
Some(config) => Configuration::Required(
ContextServerId(key),
repository_url,
config,
),
None => {
Configuration::NotAvailable(ContextServerId(key), repository_url)
}
}
} }
})) })
} }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -91,22 +118,22 @@ fn show_configure_mcp_modal(
let jsonc_language = language_registry.language_for_name("jsonc"); let jsonc_language = language_registry.language_for_name("jsonc");
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
let descriptors = futures::future::join_all(configuration_tasks).await; let configurations = futures::future::join_all(configuration_tasks).await;
let jsonc_language = jsonc_language.await.ok(); let jsonc_language = jsonc_language.await.ok();
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
let modal = ConfigureContextServerModal::new( let workspace = cx.entity().downgrade();
descriptors.into_iter().flatten(), this.toggle_modal(window, cx, |window, cx| {
context_server_store, ConfigureContextServerModal::new(
jsonc_language, configurations.into_iter(),
language_registry, context_server_store,
cx.entity().downgrade(), jsonc_language,
window, language_registry,
cx, workspace,
); window,
if let Some(modal) = modal { cx,
this.toggle_modal(window, cx, |_, _| modal); )
} });
}) })
}) })
.detach(); .detach();

View File

@ -253,7 +253,7 @@ impl RenderOnce for ModalFooter {
.mt_4() .mt_4()
.p(DynamicSpacing::Base08.rems(cx)) .p(DynamicSpacing::Base08.rems(cx))
.flex_none() .flex_none()
.justify_end() .justify_between()
.gap_1() .gap_1()
.border_t_1() .border_t_1()
.border_color(cx.theme().colors().border_variant) .border_color(cx.theme().colors().border_variant)