use std::{ sync::{Arc, Mutex}, time::Duration, }; use anyhow::{Context as _, Result}; use context_server::{ContextServerCommand, ContextServerId}; use editor::{Editor, EditorElement, EditorStyle}; use gpui::{ 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, 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 as _; use workspace::{ModalView, Workspace}; use crate::AddContextServer; enum ConfigurationTarget { New, Existing { id: ContextServerId, command: ContextServerCommand, }, Extension { id: ContextServerId, repository_url: Option, installation: Option, }, } enum ConfigurationSource { New { editor: Entity, }, Existing { editor: Entity, }, Extension { id: ContextServerId, editor: Option>, repository_url: Option, installation_instructions: Option>, settings_validator: Option, }, } impl ConfigurationSource { fn has_configuration_options(&self) -> bool { !matches!(self, ConfigurationSource::Extension { editor: None, .. }) } fn is_new(&self) -> bool { matches!(self, ConfigurationSource::New { .. }) } fn from_target( target: ConfigurationTarget, language_registry: Arc, jsonc_language: Option>, window: &mut Window, cx: &mut App, ) -> Self { 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 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())); } } Ok((id.clone(), ContextServerSettings::Extension { settings })) } } } } 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, settings) = match self.source.output(cx) { Ok(val) => val, Err(error) => { self.set_error(error.to_string(), cx); return; } }; 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 = wait_for_context_server_task.await; this.update(cx, |this, cx| match result { Ok(_) => { this.state = State::Idle; this.show_configured_context_server_toast(id, cx); cx.emit(DismissEvent); } Err(err) => { this.set_error(err, cx); } }) } }) .detach(); // When we write the settings to the file, the context server will be restarted. 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 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.0), cx, |this, _cx| { this.icon(ToastIcon::new(IconName::Hammer).color(Color::Muted)) .action("Dismiss", |_, _| {}) }, ); workspace.toggle_status_toast(status_toast, cx); } }) .log_err(); } } 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 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::