From 08c594966fdca0d7d3073eef52279bac051767fa Mon Sep 17 00:00:00 2001 From: Artur Shirokov Date: Tue, 23 Sep 2025 07:06:40 +0000 Subject: [PATCH] feat(context_server): Add HTTP/SSE transports and settings This commit introduces HTTP and SSE transport implementations for the `context_server` crate, allowing Zed to communicate with remote context providers. The new transports are built on the existing `http_client` and `gpui` executor, adhering to the project's architectural patterns. To support configuration of these new transports, the settings system has been updated: - A `Remote` variant has been added to the `ContextServerSettings` enum, allowing users to configure remote servers in `settings.json` with a URL. - The project logic has been updated to initialize remote context servers using the new `ContextServer::from_url` constructor. - The agent configuration UI now includes an "Add Remote Server" option and a dedicated modal for adding and editing remote server configurations. Unit tests have been added for the new transports and for the logic that handles remote server configurations. --- Cargo.lock | 1 - crates/agent_ui/src/agent_configuration.rs | 51 +++++- .../configure_remote_context_server_modal.rs | 173 ++++++++++++++++++ crates/agent_ui/src/agent_ui.rs | 22 ++- crates/context_server/src/transport/http.rs | 11 +- crates/context_server/src/transport/sse.rs | 2 +- crates/project/src/context_server_store.rs | 122 +++++++++--- crates/project/src/project_settings.rs | 15 ++ .../settings/src/settings_content/project.rs | 15 +- 9 files changed, 367 insertions(+), 45 deletions(-) create mode 100644 crates/agent_ui/src/agent_configuration/configure_remote_context_server_modal.rs diff --git a/Cargo.lock b/Cargo.lock index cecf2f5f70..02e7ab522f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3648,7 +3648,6 @@ dependencies = [ "settings", "smol", "tempfile", - "tokio", "url", "util", "workspace-hack", diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 3fd78c44ec..5017d2f012 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1,5 +1,6 @@ mod add_llm_provider_modal; mod configure_context_server_modal; +mod configure_remote_context_server_modal; mod configure_context_server_tools_modal; mod manage_profiles_modal; mod tool_picker; @@ -43,9 +44,12 @@ pub(crate) use configure_context_server_tools_modal::ConfigureContextServerTools pub(crate) use manage_profiles_modal::ManageProfilesModal; use crate::{ - AddContextServer, agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, + agent_configuration::configure_remote_context_server_modal::ConfigureRemoteContextServerModal, }; +use gpui::actions; + +actions!(agent_ui, [AddRemoteContextServer]); pub struct AgentConfiguration { fs: Arc, @@ -581,6 +585,9 @@ impl AgentConfiguration { menu.entry("Add Custom Server", None, { |window, cx| window.dispatch_action(AddContextServer.boxed_clone(), cx) }) + .entry("Add Remote Server", None, { + |window, cx| window.dispatch_action(AddRemoteContextServer.boxed_clone(), cx) + }) .entry("Install from Extensions", None, { |window, cx| { window.dispatch_action( @@ -704,11 +711,26 @@ impl AgentConfiguration { .map_or([].as_slice(), |tools| tools.as_slice()); let tool_count = tools.len(); + let is_remote = server_configuration + .as_ref() + .map(|config| { + matches!( + config.as_ref(), + ContextServerConfiguration::Remote { .. } + ) + }) + .unwrap_or(false); + let (source_icon, source_tooltip) = if is_from_extension { ( IconName::ZedMcpExtension, "This MCP server was installed from an extension.", ) + } else if is_remote { + ( + IconName::Server, + "This is a remote MCP server.", + ) } else { ( IconName::ZedMcpCustom, @@ -764,15 +786,26 @@ impl AgentConfiguration { let context_server_id = context_server_id.clone(); let language_registry = language_registry.clone(); let workspace = workspace.clone(); + let is_remote = is_remote; 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); + if is_remote { + ConfigureRemoteContextServerModal::show_modal_for_existing_server( + context_server_id.clone(), + workspace.clone(), + window, + cx, + ) + .detach_and_log_err(cx); + } else { + ConfigureContextServerModal::show_modal_for_existing_server( + context_server_id.clone(), + language_registry.clone(), + workspace.clone(), + window, + cx, + ) + .detach_and_log_err(cx); + } } }).when(tool_count >= 1, |this| this.entry("View Tools", None, { let context_server_id = context_server_id.clone(); diff --git a/crates/agent_ui/src/agent_configuration/configure_remote_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_remote_context_server_modal.rs new file mode 100644 index 0000000000..928e2878a3 --- /dev/null +++ b/crates/agent_ui/src/agent_configuration/configure_remote_context_server_modal.rs @@ -0,0 +1,173 @@ +use crate::agent_configuration::AddContextServer; +use gpui::{ + actions, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle, + Focusable, Global, Subscription, Task, WeakEntity, Window, +}; +use language::LanguageRegistry; +use project::{ + context_server_store::ContextServerStore, + project_settings::{ContextServerSettings, ProjectSettings}, + Project, +}; +use settings::{Settings, SettingsStore, update_settings_file}; +use std::sync::Arc; +use ui::{ + prelude::*, Button, ButtonStyle, Checkbox, Divider, Editor, EditorWithAction, Icon, + IconButton, IconName, Label, Select, Switch, SwitchColor, TextField, +}; +use workspace::Workspace; + +pub struct ConfigureRemoteContextServerModal { + fs: Arc, + server_id: Option, + server_name_editor: Entity, + server_url_editor: Entity, + workspace: WeakEntity, + focus_handle: FocusHandle, +} + +actions!( + remote_context_server_modal, + [Submit, Dismiss, AddContextServer] +); + +impl ConfigureRemoteContextServerModal { + pub fn new( + workspace: WeakEntity, + server_id: Option, + cx: &mut Context, + ) -> Self { + let fs = workspace.read(cx).app_state().fs.clone(); + let server_name_editor = cx.new(|cx| Editor::single_line(Some(cx.text_style()), cx)); + let server_url_editor = cx.new(|cx| Editor::single_line(Some(cx.text_style()), cx)); + let focus_handle = cx.focus_handle(); + + if let Some(server_id) = &server_id { + server_name_editor.update(cx, |editor, cx| { + editor.set_text(server_id.0.to_string(), cx); + }); + + if let Some(project) = workspace.read(cx).project().as_ref() { + let settings = ProjectSettings::get_global(cx); + if let Some(ContextServerSettings::Remote { url, .. }) = + settings.context_servers.get(&server_id.0) + { + server_url_editor.update(cx, |editor, cx| { + editor.set_text(url.clone(), cx); + }); + } + } + } + + Self { + fs, + server_id, + server_name_editor, + server_url_editor, + workspace, + focus_handle, + } + } + + pub fn toggle( + workspace: &mut Workspace, + _: &super::AddRemoteContextServer, + window: &mut Window, + cx: &mut AppContext, + ) { + window.toggle_modal(cx, |cx| { + Self::new(workspace.weak_handle(), None, cx) + }); + } + + pub fn show_modal_for_existing_server( + server_id: ContextServerId, + workspace: WeakEntity, + window: &mut Window, + cx: &mut AppContext, + ) -> Task<()> { + let task = window + .new_modal(cx, |cx| Self::new(workspace, Some(server_id), cx)) + .log_err(); + cx.spawn(|_, _| async move { + task.await; + }) + } + + fn submit(&mut self, _: &Submit, window: &mut Window, cx: &mut AppContext) { + let server_name = self.server_name_editor.read(cx).text(cx); + let server_url = self.server_url_editor.read(cx).text(cx); + + if server_name.is_empty() || server_url.is_empty() { + return; + } + + let settings_path = SettingsStore::global(cx).read(cx).user_settings_file_path(); + let fs = self.fs.clone(); + let server_id = self.server_id.clone(); + cx.spawn(|_cx| async move { + update_settings_file::(fs, settings_path, |settings| { + if let Some(server_id) = server_id { + if server_id.0.as_ref() != server_name { + settings.context_servers.remove(&server_id.0); + } + } + + settings + .context_servers + .insert(server_name.into(), ContextServerSettings::Remote { + enabled: true, + url: server_url, + }); + }) + .await + }) + .detach_and_log_err(cx); + + self.dismiss(&Dismiss, window, cx); + } +} + +impl Focusable for ConfigureRemoteContextServerModal { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ConfigureRemoteContextServerModal { + fn render(&mut self, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("ConfigureRemoteContextServerModal") + .on_action(cx.listener(Self::submit)) + .on_action(cx.listener(Self::dismiss)) + .w_96() + .gap_4() + .child( + v_flex() + .gap_2() + .child(Label::new("Server Name")) + .child(TextField::new(self.server_name_editor.clone())), + ) + .child( + v_flex() + .gap_2() + .child(Label::new("Server URL")) + .child(TextField::new(self.server_url_editor.clone())), + ) + .child( + h_flex() + .justify_end() + .gap_2() + .child(Button::new("cancel", "Cancel").on_click(cx.listener(Self::dismiss))) + .child(Button::new("submit", "Add Server").on_click(cx.listener(Self::submit))), + ) + } +} + +impl Modal for ConfigureRemoteContextServerModal { + fn on_before_dismiss(&mut self, _cx: &mut AppContext) -> bool { + false + } + + fn on_after_dismiss(&mut self, _action: &Self::Action, _cx: &mut AppContext) {} +} diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index e34789d62d..df28035e0e 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -42,7 +42,9 @@ use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, Settings as _, SettingsStore}; use std::any::TypeId; -use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; +use crate::agent_configuration::{ + AddRemoteContextServer, ConfigureContextServerModal, ManageProfilesModal, +}; pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate}; pub use crate::inline_assistant::InlineAssistant; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; @@ -281,6 +283,24 @@ pub fn init( ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx) }) .detach(); + cx.observe_new(move |workspace, window, cx| { + window.on_action({ + let workspace = workspace.weak_handle(); + move |_: &AddRemoteContextServer, cx| { + if let Some(workspace) = workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + agent_configuration::configure_remote_context_server_modal::ConfigureRemoteContextServerModal::toggle( + workspace, + &AddRemoteContextServer, + cx.window(), + cx, + ) + }); + } + } + }); + }) + .detach(); cx.observe_new(ManageProfilesModal::register).detach(); // Update command palette filter based on AI settings diff --git a/crates/context_server/src/transport/http.rs b/crates/context_server/src/transport/http.rs index bd67e2d918..4662248f1a 100644 --- a/crates/context_server/src/transport/http.rs +++ b/crates/context_server/src/transport/http.rs @@ -1,11 +1,11 @@ use anyhow::{Result, anyhow}; use async_trait::async_trait; -use futures::{io::AsyncReadExt, stream, Stream, StreamExt}; +use futures::{io::AsyncReadExt, stream, Stream}; use http_client::{ http::Method, AsyncBody, HttpClient, Request, }; -use postage::prelude::Sink; +use postage::prelude::{Sink, Stream as _}; use std::{ pin::Pin, sync::{Arc, Mutex}, @@ -43,7 +43,7 @@ impl Transport for HttpTransport { let http_client = self.http_client.clone(); let mut tx = self.tx.lock().unwrap().take().ok_or_else(|| anyhow!("transport already used"))?; - gpui::spawn(async move { + smol::spawn(async move { let res = async { let mut response = http_client.send(request).await?; @@ -67,7 +67,10 @@ impl Transport for HttpTransport { fn receive(&self) -> Pin + Send>> { if let Some(mut rx) = self.rx.lock().unwrap().take() { Box::pin(stream::once(async move { - rx.recv().await.unwrap_or_else(|_| Ok("".to_string())).unwrap_or_default() + match rx.recv().await { + Some(Ok(response)) => response, + _ => "".to_string(), + } })) } else { Box::pin(stream::empty()) diff --git a/crates/context_server/src/transport/sse.rs b/crates/context_server/src/transport/sse.rs index ea4af879fc..ebe452a15c 100644 --- a/crates/context_server/src/transport/sse.rs +++ b/crates/context_server/src/transport/sse.rs @@ -53,7 +53,7 @@ impl Transport for SseTransport { let mut tx = self.tx.lock().unwrap().take().ok_or_else(|| anyhow!("transport already used"))?; let mut err_tx = self.err_tx.lock().unwrap().take().ok_or_else(|| anyhow!("transport already used"))?; - gpui::spawn(async move { + smol::spawn(async move { let response = http_client.send(request).await; match response { Ok(response) => { diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 364128ae4f..e19fab379c 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -99,13 +99,17 @@ pub enum ContextServerConfiguration { command: ContextServerCommand, settings: serde_json::Value, }, + Remote { + url: url::Url, + }, } impl ContextServerConfiguration { - pub fn command(&self) -> &ContextServerCommand { + pub fn command(&self) -> Option<&ContextServerCommand> { match self { - ContextServerConfiguration::Custom { command } => command, - ContextServerConfiguration::Extension { command, .. } => command, + ContextServerConfiguration::Custom { command } => Some(command), + ContextServerConfiguration::Extension { command, .. } => Some(command), + ContextServerConfiguration::Remote { .. } => None, } } @@ -134,6 +138,10 @@ impl ContextServerConfiguration { Some(ContextServerConfiguration::Extension { command, settings }) } + ContextServerSettings::Remote { enabled: _, url } => { + let url = url::Url::parse(&url).log_err()?; + Some(ContextServerConfiguration::Remote { url }) + } } } } @@ -472,31 +480,37 @@ impl ContextServerStore { configuration: Arc, cx: &mut Context, ) -> Arc { - let root_path = self - .project - .read_with(cx, |project, cx| project.active_project_directory(cx)) - .ok() - .flatten() - .or_else(|| { - self.worktree_store.read_with(cx, |store, cx| { - store.visible_worktrees(cx).fold(None, |acc, item| { - if acc.is_none() { - item.read(cx).root_dir() - } else { - acc - } - }) - }) - }); - if let Some(factory) = self.context_server_factory.as_ref() { - factory(id, configuration) - } else { - Arc::new(ContextServer::stdio( - id, - configuration.command().clone(), - root_path, - )) + return factory(id, configuration); + } + + match configuration.as_ref() { + ContextServerConfiguration::Remote { url } => { + Arc::new(ContextServer::from_url(id, url, cx).unwrap()) + } + _ => { + let root_path = self + .project + .read_with(cx, |project, cx| project.active_project_directory(cx)) + .ok() + .flatten() + .or_else(|| { + self.worktree_store.read_with(cx, |store, cx| { + store.visible_worktrees(cx).fold(None, |acc, item| { + if acc.is_none() { + item.read(cx).root_dir() + } else { + acc + } + }) + }) + }); + Arc::new(ContextServer::stdio( + id, + configuration.command().unwrap().clone(), + root_path, + )) + } } } @@ -1219,6 +1233,60 @@ mod tests { }); } + #[gpui::test] + async fn test_remote_context_server(cx: &mut TestAppContext) { + const SERVER_ID: &str = "remote-server"; + let server_id = ContextServerId(SERVER_ID.into()); + let server_url = "http://example.com/api"; + + let (_fs, project) = setup_context_server_test( + cx, + json!({ "code.rs": "" }), + vec![( + SERVER_ID.into(), + ContextServerSettings::Remote { + enabled: true, + url: server_url.to_string(), + }, + )], + ) + .await; + + let executor = cx.executor(); + let registry = cx.new(|_| ContextServerDescriptorRegistry::new()); + let store = cx.new(|cx| { + ContextServerStore::test_maintain_server_loop( + Box::new(move |id, config| { + assert_eq!(id.0.as_ref(), SERVER_ID); + match config.as_ref() { + ContextServerConfiguration::Remote { url } => { + assert_eq!(url.as_str(), server_url); + } + _ => panic!("Expected remote configuration"), + } + Arc::new(ContextServer::new( + id.clone(), + Arc::new(create_fake_transport(id.0.to_string(), executor.clone())), + )) + }), + registry.clone(), + project.read(cx).worktree_store(), + project.downgrade(), + cx, + ) + }); + + let _server_events = assert_server_events( + &store, + vec![ + (server_id.clone(), ContextServerStatus::Starting), + (server_id.clone(), ContextServerStatus::Running), + ], + cx, + ); + cx.run_until_parked(); + } + struct ServerEvents { received_event_count: Rc>, expected_event_count: usize, diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 369d445b20..56451c7cb3 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -139,6 +139,13 @@ pub enum ContextServerSettings { /// are supported. settings: serde_json::Value, }, + Remote { + /// Whether the context server is enabled. + #[serde(default = "default_true")] + enabled: bool, + /// The URL of the remote context server. + url: String, + }, } impl From for ContextServerSettings { @@ -150,6 +157,9 @@ impl From for ContextServerSettings { settings::ContextServerSettingsContent::Extension { enabled, settings } => { ContextServerSettings::Extension { enabled, settings } } + settings::ContextServerSettingsContent::Remote { enabled, url } => { + ContextServerSettings::Remote { enabled, url } + } } } } @@ -162,6 +172,9 @@ impl Into for ContextServerSettings { ContextServerSettings::Extension { enabled, settings } => { settings::ContextServerSettingsContent::Extension { enabled, settings } } + ContextServerSettings::Remote { enabled, url } => { + settings::ContextServerSettingsContent::Remote { enabled, url } + } } } } @@ -178,6 +191,7 @@ impl ContextServerSettings { match self { ContextServerSettings::Custom { enabled, .. } => *enabled, ContextServerSettings::Extension { enabled, .. } => *enabled, + ContextServerSettings::Remote { enabled, .. } => *enabled, } } @@ -185,6 +199,7 @@ impl ContextServerSettings { match self { ContextServerSettings::Custom { enabled: e, .. } => *e = enabled, ContextServerSettings::Extension { enabled: e, .. } => *e = enabled, + ContextServerSettings::Remote { enabled: e, .. } => *e = enabled, } } } diff --git a/crates/settings/src/settings_content/project.rs b/crates/settings/src/settings_content/project.rs index b44f77fcf1..4a7ca750e0 100644 --- a/crates/settings/src/settings_content/project.rs +++ b/crates/settings/src/settings_content/project.rs @@ -186,20 +186,31 @@ pub enum ContextServerSettingsContent { /// are supported. settings: serde_json::Value, }, + Remote { + /// Whether the context server is enabled. + #[serde(default = "default_true")] + enabled: bool, + /// The URL of the remote context server. + url: String, + }, } impl ContextServerSettingsContent { pub fn set_enabled(&mut self, enabled: bool) { match self { ContextServerSettingsContent::Custom { enabled: custom_enabled, - command: _, + .. } => { *custom_enabled = enabled; } ContextServerSettingsContent::Extension { enabled: ext_enabled, - settings: _, + .. } => *ext_enabled = enabled, + ContextServerSettingsContent::Remote { + enabled: remote_enabled, + .. + } => *remote_enabled = enabled, } } }