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.
This commit is contained in:
Artur Shirokov
2025-09-23 07:06:40 +00:00
parent 59a9ec6fb3
commit 08c594966f
9 changed files with 367 additions and 45 deletions

1
Cargo.lock generated
View File

@@ -3648,7 +3648,6 @@ dependencies = [
"settings",
"smol",
"tempfile",
"tokio",
"url",
"util",
"workspace-hack",

View File

@@ -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<dyn Fs>,
@@ -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();

View File

@@ -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<fs::Fs>,
server_id: Option<ContextServerId>,
server_name_editor: Entity<Editor>,
server_url_editor: Entity<Editor>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
}
actions!(
remote_context_server_modal,
[Submit, Dismiss, AddContextServer]
);
impl ConfigureRemoteContextServerModal {
pub fn new(
workspace: WeakEntity<Workspace>,
server_id: Option<ContextServerId>,
cx: &mut Context<Self>,
) -> 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<Workspace>,
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::<ProjectSettings>(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<Self>) -> 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) {}
}

View File

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

View File

@@ -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<Box<dyn Stream<Item = String> + 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())

View File

@@ -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) => {

View File

@@ -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<ContextServerConfiguration>,
cx: &mut Context<Self>,
) -> Arc<ContextServer> {
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<RefCell<usize>>,
expected_event_count: usize,

View File

@@ -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<settings::ContextServerSettingsContent> for ContextServerSettings {
@@ -150,6 +157,9 @@ impl From<settings::ContextServerSettingsContent> 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<settings::ContextServerSettingsContent> 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,
}
}
}

View File

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