From 03132921c7ad0c6e9d5f210c1a10ed64f3d53a54 Mon Sep 17 00:00:00 2001 From: Artur Shirokov Date: Tue, 18 Nov 2025 16:39:08 +0000 Subject: [PATCH] Add HTTP transport support for MCP servers (#39021) ### What this solves This PR adds support for HTTP and SSE (Server-Sent Events) transports to Zed's context server implementation, enabling communication with remote MCP servers. Currently, Zed only supports local MCP servers via stdio transport. This limitation prevents users from: - Connecting to cloud-hosted MCP servers - Using MCP servers running in containers or on remote machines - Leveraging MCP servers that are designed to work over HTTP/SSE ### Why it's important The MCP (Model Context Protocol) specification includes HTTP/SSE as standard transport options, and many MCP server implementations are being built with these transports in mind. Without this support, Zed users are limited to a subset of the MCP ecosystem. This is particularly important for: - Enterprise users who need to connect to centralized MCP services - Developers working with MCP servers that require network isolation - Users wanting to leverage cloud-based context providers (e.g., knowledge bases, API integrations) ### Implementation approach The implementation follows Zed's existing architectural patterns: - **Transports**: Added `HttpTransport` and `SseTransport` to the `context_server` crate, built on top of the existing `http_client` crate - **Async handling**: Uses `gpui::spawn` for network operations instead of introducing a new Tokio runtime - **Settings**: Extended `ContextServerSettings` enum with a `Remote` variant to support URL-based configuration - **UI**: Updated the agent configuration UI with an "Add Remote Server" option and dedicated modal for remote server management ### Changes included - [x] HTTP transport implementation with request/response handling - [x] SSE transport for server-sent events streaming - [x] `build_transport` function to construct appropriate transport based on URL scheme - [x] Settings system updates to support remote server configuration - [x] UI updates for adding/editing remote servers - [x] Unit tests using `FakeHttpClient` for both transports - [x] Integration tests (WIP) - [x] Documentation updates (WIP) ### Testing - Unit tests for both `HttpTransport` and `SseTransport` using mocked HTTP client - Manual testing with example MCP servers over HTTP/SSE - Settings validation and UI interaction testing ### Screenshots/Recordings [TODO: Add screenshots of the new "Add Remote Server" UI and configuration modal] ### Example configuration Users can now configure remote MCP servers in their `settings.json`: ```json { "context_servers": { "my-remote-server": { "enabled": true, "url": "http://localhost:3000/mcp" } } } ``` ### AI assistance disclosure I used AI to help with: - Understanding the MCP protocol specification and how HTTP/SSE transports should work - Reviewing Zed's existing patterns for async operations and suggesting consistent approaches - Generating boilerplate for test cases - Debugging SSE streaming issues All code has been manually reviewed, tested, and adapted to fit Zed's architecture. The core logic, architectural decisions, and integration with Zed's systems were done with human understanding of the codebase. AI was primarily used as a reference tool and for getting unstuck on specific technical issues. Release notes: * You can now configure MCP Servers that connect over HTTP in your settings file. These are not yet available in the extensions API. ``` { "context_servers": { "my-remote-server": { "enabled": true, "url": "http://localhost:3000/mcp" } } } ``` --------- Co-authored-by: Conrad Irwin --- Cargo.lock | 1 + crates/agent_servers/src/acp.rs | 81 ++++-- crates/agent_ui/src/agent_configuration.rs | 45 ++- .../configure_context_server_modal.rs | 165 ++++++++++- crates/context_server/Cargo.toml | 6 +- crates/context_server/src/context_server.rs | 24 ++ crates/context_server/src/transport.rs | 5 +- crates/context_server/src/transport/http.rs | 259 ++++++++++++++++++ .../src/wasm_host/wit/since_v0_6_0.rs | 3 + crates/project/src/context_server_store.rs | 175 ++++++++---- crates/project/src/project_settings.rs | 30 ++ .../settings/src/settings_content/project.rs | 21 +- crates/zed/src/zed.rs | 127 --------- docs/src/ai/mcp.md | 7 +- 14 files changed, 709 insertions(+), 240 deletions(-) create mode 100644 crates/context_server/src/transport/http.rs diff --git a/Cargo.lock b/Cargo.lock index c0f6ef03c296306a73264461a8767ccd1b346c20..09ee945d1c34cf1ae93a3cc538d62860ad3a1c78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3690,6 +3690,7 @@ dependencies = [ "collections", "futures 0.3.31", "gpui", + "http_client", "log", "net", "parking_lot", diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 15f56bf2ed4ee100fd22dc0d7df73f2e8a3274ea..2ec9beb71bf08c90ea85b8752410405714d31537 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -247,37 +247,58 @@ impl AgentConnection for AcpConnection { let default_mode = self.default_mode.clone(); let cwd = cwd.to_path_buf(); let context_server_store = project.read(cx).context_server_store().read(cx); - let mcp_servers = if project.read(cx).is_local() { - context_server_store - .configured_server_ids() - .iter() - .filter_map(|id| { - let configuration = context_server_store.configuration_for_server(id)?; - let command = configuration.command(); - Some(acp::McpServer::Stdio { - name: id.0.to_string(), - command: command.path.clone(), - args: command.args.clone(), - env: if let Some(env) = command.env.as_ref() { - env.iter() - .map(|(name, value)| acp::EnvVariable { - name: name.clone(), - value: value.clone(), - meta: None, - }) - .collect() - } else { - vec![] - }, + let mcp_servers = + if project.read(cx).is_local() { + context_server_store + .configured_server_ids() + .iter() + .filter_map(|id| { + let configuration = context_server_store.configuration_for_server(id)?; + match &*configuration { + project::context_server_store::ContextServerConfiguration::Custom { + command, + .. + } + | project::context_server_store::ContextServerConfiguration::Extension { + command, + .. + } => Some(acp::McpServer::Stdio { + name: id.0.to_string(), + command: command.path.clone(), + args: command.args.clone(), + env: if let Some(env) = command.env.as_ref() { + env.iter() + .map(|(name, value)| acp::EnvVariable { + name: name.clone(), + value: value.clone(), + meta: None, + }) + .collect() + } else { + vec![] + }, + }), + project::context_server_store::ContextServerConfiguration::Http { + url, + headers, + } => Some(acp::McpServer::Http { + name: id.0.to_string(), + url: url.to_string(), + headers: headers.iter().map(|(name, value)| acp::HttpHeader { + name: name.clone(), + value: value.clone(), + meta: None, + }).collect(), + }), + } }) - }) - .collect() - } else { - // In SSH projects, the external agent is running on the remote - // machine, and currently we only run MCP servers on the local - // machine. So don't pass any MCP servers to the agent in that case. - Vec::new() - }; + .collect() + } else { + // In SSH projects, the external agent is running on the remote + // machine, and currently we only run MCP servers on the local + // machine. So don't pass any MCP servers to the agent in that case. + Vec::new() + }; cx.spawn(async move |cx| { let response = conn diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 125dc223796f6d9b7e96bee452bee25a2409adb1..60f8606baf7bcbd55a7e4bd9ee6dc44f394319bc 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1,5 +1,5 @@ mod add_llm_provider_modal; -mod configure_context_server_modal; +pub mod configure_context_server_modal; mod configure_context_server_tools_modal; mod manage_profiles_modal; mod tool_picker; @@ -46,9 +46,8 @@ pub(crate) use configure_context_server_modal::ConfigureContextServerModal; pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal; pub(crate) use manage_profiles_modal::ManageProfilesModal; -use crate::{ - AddContextServer, - agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, +use crate::agent_configuration::add_llm_provider_modal::{ + AddLlmProviderModal, LlmCompatibleProvider, }; pub struct AgentConfiguration { @@ -553,7 +552,9 @@ impl AgentConfiguration { move |window, cx| { Some(ContextMenu::build(window, cx, |menu, _window, _cx| { menu.entry("Add Custom Server", None, { - |window, cx| window.dispatch_action(AddContextServer.boxed_clone(), cx) + |window, cx| { + window.dispatch_action(crate::AddContextServer.boxed_clone(), cx) + } }) .entry("Install from Extensions", None, { |window, cx| { @@ -651,7 +652,7 @@ impl AgentConfiguration { let is_running = matches!(server_status, ContextServerStatus::Running); let item_id = SharedString::from(context_server_id.0.clone()); // Servers without a configuration can only be provided by extensions. - let provided_by_extension = server_configuration.is_none_or(|config| { + let provided_by_extension = server_configuration.as_ref().is_none_or(|config| { matches!( config.as_ref(), ContextServerConfiguration::Extension { .. } @@ -707,7 +708,10 @@ impl AgentConfiguration { "Server is stopped.", ), }; - + let is_remote = server_configuration + .as_ref() + .map(|config| matches!(config.as_ref(), ContextServerConfiguration::Http { .. })) + .unwrap_or(false); let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu") .trigger_with_tooltip( IconButton::new("context-server-config-menu", IconName::Settings) @@ -730,14 +734,25 @@ impl AgentConfiguration { 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); + if is_remote { + crate::agent_configuration::configure_context_server_modal::ConfigureContextServerModal::show_modal_for_existing_server( + context_server_id.clone(), + language_registry.clone(), + workspace.clone(), + window, + cx, + ) + .detach(); + } else { + ConfigureContextServerModal::show_modal_for_existing_server( + context_server_id.clone(), + language_registry.clone(), + workspace.clone(), + window, + cx, + ) + .detach(); + } } }).when(tool_count > 0, |this| this.entry("View Tools", None, { let context_server_id = context_server_id.clone(); diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index ed1e8afd1b3b3220d31119f7292b6b0934cd2ba7..ebea8c25fb68a8a5055d4ccaa8b9068583c4b91c 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -4,6 +4,7 @@ use std::{ }; use anyhow::{Context as _, Result}; +use collections::HashMap; use context_server::{ContextServerCommand, ContextServerId}; use editor::{Editor, EditorElement, EditorStyle}; use gpui::{ @@ -20,6 +21,7 @@ use project::{ project_settings::{ContextServerSettings, ProjectSettings}, worktree_store::WorktreeStore, }; +use serde::Deserialize; use settings::{Settings as _, update_settings_file}; use theme::ThemeSettings; use ui::{ @@ -37,6 +39,11 @@ enum ConfigurationTarget { id: ContextServerId, command: ContextServerCommand, }, + ExistingHttp { + id: ContextServerId, + url: String, + headers: HashMap, + }, Extension { id: ContextServerId, repository_url: Option, @@ -47,9 +54,11 @@ enum ConfigurationTarget { enum ConfigurationSource { New { editor: Entity, + is_http: bool, }, Existing { editor: Entity, + is_http: bool, }, Extension { id: ContextServerId, @@ -97,6 +106,7 @@ impl ConfigurationSource { match target { ConfigurationTarget::New => ConfigurationSource::New { editor: create_editor(context_server_input(None), jsonc_language, window, cx), + is_http: false, }, ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing { editor: create_editor( @@ -105,6 +115,20 @@ impl ConfigurationSource { window, cx, ), + is_http: false, + }, + ConfigurationTarget::ExistingHttp { + id, + url, + headers: auth, + } => ConfigurationSource::Existing { + editor: create_editor( + context_server_http_input(Some((id, url, auth))), + jsonc_language, + window, + cx, + ), + is_http: true, }, ConfigurationTarget::Extension { id, @@ -141,16 +165,30 @@ impl ConfigurationSource { 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 { - enabled: true, - command, - }, - ) - }) + ConfigurationSource::New { editor, is_http } + | ConfigurationSource::Existing { editor, is_http } => { + if *is_http { + parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth)| { + ( + id, + ContextServerSettings::Http { + enabled: true, + url, + headers: auth, + }, + ) + }) + } else { + parse_input(&editor.read(cx).text(cx)).map(|(id, command)| { + ( + id, + ContextServerSettings::Custom { + enabled: true, + command, + }, + ) + }) + } } ConfigurationSource::Extension { id, @@ -212,6 +250,66 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) ) } +fn context_server_http_input( + existing: Option<(ContextServerId, String, HashMap)>, +) -> String { + let (name, url, headers) = match existing { + Some((id, url, headers)) => { + let header = if headers.is_empty() { + r#"// "Authorization": "Bearer "#.to_string() + } else { + let json = serde_json::to_string_pretty(&headers).unwrap(); + let mut lines = json.split("\n").collect::>(); + if lines.len() > 1 { + lines.remove(0); + lines.pop(); + } + lines + .into_iter() + .map(|line| format!(" {}", line)) + .collect::() + }; + (id.0.to_string(), url, header) + } + None => ( + "some-remote-server".to_string(), + "https://example.com/mcp".to_string(), + r#"// "Authorization": "Bearer "#.to_string(), + ), + }; + + format!( + r#"{{ + /// The name of your remote MCP server + "{name}": {{ + /// The URL of the remote MCP server + "url": "{url}", + "headers": {{ + /// Any headers to send along + {headers} + }} + }} +}}"# + ) +} + +fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap)> { + #[derive(Deserialize)] + struct Temp { + url: String, + #[serde(default)] + headers: HashMap, + } + let value: HashMap = serde_json_lenient::from_str(text)?; + if value.len() != 1 { + anyhow::bail!("Expected exactly one context server configuration"); + } + + let (key, value) = value.into_iter().next().unwrap(); + + Ok((ContextServerId(key.into()), value.url, value.headers)) +} + fn resolve_context_server_extension( id: ContextServerId, worktree_store: Entity, @@ -312,6 +410,15 @@ impl ConfigureContextServerModal { id: server_id, command, }), + ContextServerSettings::Http { + enabled: _, + url, + headers, + } => Some(ConfigurationTarget::ExistingHttp { + id: server_id, + url, + headers, + }), ContextServerSettings::Extension { .. } => { match workspace .update(cx, |workspace, cx| { @@ -353,6 +460,7 @@ impl ConfigureContextServerModal { state: State::Idle, original_server_id: match &target { ConfigurationTarget::Existing { id, .. } => Some(id.clone()), + ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()), ConfigurationTarget::Extension { id, .. } => Some(id.clone()), ConfigurationTarget::New => None, }, @@ -481,7 +589,7 @@ 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::New { editor, .. } => editor.focus_handle(cx), ConfigurationSource::Existing { editor, .. } => editor.focus_handle(cx), ConfigurationSource::Extension { editor, .. } => editor .as_ref() @@ -527,9 +635,10 @@ impl ConfigureContextServerModal { } fn render_modal_content(&self, cx: &App) -> AnyElement { + // All variants now use single editor approach let editor = match &self.source { - ConfigurationSource::New { editor } => editor, - ConfigurationSource::Existing { editor } => editor, + ConfigurationSource::New { editor, .. } => editor, + ConfigurationSource::Existing { editor, .. } => editor, ConfigurationSource::Extension { editor, .. } => { let Some(editor) = editor else { return div().into_any_element(); @@ -601,6 +710,36 @@ impl ConfigureContextServerModal { move |_, _, cx| cx.open_url(&repository_url) }), ) + } else if let ConfigurationSource::New { is_http, .. } = &self.source { + let label = if *is_http { + "Run command" + } else { + "Connect via HTTP" + }; + let tooltip = if *is_http { + "Configure an MCP serevr that runs on stdin/stdout." + } else { + "Configure an MCP server that you connect to over HTTP" + }; + + Some( + Button::new("toggle-kind", label) + .tooltip(Tooltip::text(tooltip)) + .on_click(cx.listener(|this, _, window, cx| match &mut this.source { + ConfigurationSource::New { editor, is_http } => { + *is_http = !*is_http; + let new_text = if *is_http { + context_server_http_input(None) + } else { + context_server_input(None) + }; + editor.update(cx, |editor, cx| { + editor.set_text(new_text, window, cx); + }) + } + _ => {} + })), + ) } else { None }, diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index 846a53fde4b6f87493ec2b75da6c08d2b081df47..f73e6a9bab011c5d675040d1ee3a05dfa708dc45 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -12,7 +12,7 @@ workspace = true path = "src/context_server.rs" [features] -test-support = [] +test-support = ["gpui/test-support"] [dependencies] anyhow.workspace = true @@ -20,6 +20,7 @@ async-trait.workspace = true collections.workspace = true futures.workspace = true gpui.workspace = true +http_client = { workspace = true, features = ["test-support"] } log.workspace = true net.workspace = true parking_lot.workspace = true @@ -32,3 +33,6 @@ smol.workspace = true tempfile.workspace = true url = { workspace = true, features = ["serde"] } util.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index 52ed524220947430df3e63fced367ca4eb223fff..553e845df87a2fec30b1afbffa05b970d5d672f6 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -6,6 +6,8 @@ pub mod test; pub mod transport; pub mod types; +use collections::HashMap; +use http_client::HttpClient; use std::path::Path; use std::sync::Arc; use std::{fmt::Display, path::PathBuf}; @@ -15,6 +17,9 @@ use client::Client; use gpui::AsyncApp; use parking_lot::RwLock; pub use settings::ContextServerCommand; +use url::Url; + +use crate::transport::HttpTransport; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ContextServerId(pub Arc); @@ -52,6 +57,25 @@ impl ContextServer { } } + pub fn http( + id: ContextServerId, + endpoint: &Url, + headers: HashMap, + http_client: Arc, + executor: gpui::BackgroundExecutor, + ) -> Result { + let transport = match endpoint.scheme() { + "http" | "https" => { + log::info!("Using HTTP transport for {}", endpoint); + let transport = + HttpTransport::new(http_client, endpoint.to_string(), headers, executor); + Arc::new(transport) as _ + } + _ => anyhow::bail!("unsupported MCP url scheme {}", endpoint.scheme()), + }; + Ok(Self::new(id, transport)) + } + pub fn new(id: ContextServerId, transport: Arc) -> Self { Self { id, diff --git a/crates/context_server/src/transport.rs b/crates/context_server/src/transport.rs index b4f56b0ef03ac6adf4ee81f878818ec3fecc5ef9..a3d6f998d49872c44513da00c506b68534c36b65 100644 --- a/crates/context_server/src/transport.rs +++ b/crates/context_server/src/transport.rs @@ -1,11 +1,12 @@ +pub mod http; mod stdio_transport; -use std::pin::Pin; - use anyhow::Result; use async_trait::async_trait; use futures::Stream; +use std::pin::Pin; +pub use http::*; pub use stdio_transport::*; #[async_trait] diff --git a/crates/context_server/src/transport/http.rs b/crates/context_server/src/transport/http.rs new file mode 100644 index 0000000000000000000000000000000000000000..70248f0278fcf80024d75d7f78cae5c29f26cc43 --- /dev/null +++ b/crates/context_server/src/transport/http.rs @@ -0,0 +1,259 @@ +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use collections::HashMap; +use futures::{Stream, StreamExt}; +use gpui::BackgroundExecutor; +use http_client::{AsyncBody, HttpClient, Request, Response, http::Method}; +use parking_lot::Mutex as SyncMutex; +use smol::channel; +use std::{pin::Pin, sync::Arc}; + +use crate::transport::Transport; + +// Constants from MCP spec +const HEADER_SESSION_ID: &str = "Mcp-Session-Id"; +const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; +const JSON_MIME_TYPE: &str = "application/json"; + +/// HTTP Transport with session management and SSE support +pub struct HttpTransport { + http_client: Arc, + endpoint: String, + session_id: Arc>>, + executor: BackgroundExecutor, + response_tx: channel::Sender, + response_rx: channel::Receiver, + error_tx: channel::Sender, + error_rx: channel::Receiver, + // Authentication headers to include in requests + headers: HashMap, +} + +impl HttpTransport { + pub fn new( + http_client: Arc, + endpoint: String, + headers: HashMap, + executor: BackgroundExecutor, + ) -> Self { + let (response_tx, response_rx) = channel::unbounded(); + let (error_tx, error_rx) = channel::unbounded(); + + Self { + http_client, + executor, + endpoint, + session_id: Arc::new(SyncMutex::new(None)), + response_tx, + response_rx, + error_tx, + error_rx, + headers, + } + } + + /// Send a message and handle the response based on content type + async fn send_message(&self, message: String) -> Result<()> { + let is_notification = + !message.contains("\"id\":") || message.contains("notifications/initialized"); + + let mut request_builder = Request::builder() + .method(Method::POST) + .uri(&self.endpoint) + .header("Content-Type", JSON_MIME_TYPE) + .header( + "Accept", + format!("{}, {}", JSON_MIME_TYPE, EVENT_STREAM_MIME_TYPE), + ); + + for (key, value) in &self.headers { + request_builder = request_builder.header(key.as_str(), value.as_str()); + } + + // Add session ID if we have one (except for initialize) + if let Some(ref session_id) = *self.session_id.lock() { + request_builder = request_builder.header(HEADER_SESSION_ID, session_id.as_str()); + } + + let request = request_builder.body(AsyncBody::from(message.into_bytes()))?; + let mut response = self.http_client.send(request).await?; + + // Handle different response types based on status and content-type + match response.status() { + status if status.is_success() => { + // Check content type + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()); + + // Extract session ID from response headers if present + if let Some(session_id) = response + .headers() + .get(HEADER_SESSION_ID) + .and_then(|v| v.to_str().ok()) + { + *self.session_id.lock() = Some(session_id.to_string()); + log::debug!("Session ID set: {}", session_id); + } + + match content_type { + Some(ct) if ct.starts_with(JSON_MIME_TYPE) => { + // JSON response - read and forward immediately + let mut body = String::new(); + futures::AsyncReadExt::read_to_string(response.body_mut(), &mut body) + .await?; + + // Only send non-empty responses + if !body.is_empty() { + self.response_tx + .send(body) + .await + .map_err(|_| anyhow!("Failed to send JSON response"))?; + } + } + Some(ct) if ct.starts_with(EVENT_STREAM_MIME_TYPE) => { + // SSE stream - set up streaming + self.setup_sse_stream(response).await?; + } + _ => { + // For notifications, 202 Accepted with no content type is ok + if is_notification && status.as_u16() == 202 { + log::debug!("Notification accepted"); + } else { + return Err(anyhow!("Unexpected content type: {:?}", content_type)); + } + } + } + } + status if status.as_u16() == 202 => { + // Accepted - notification acknowledged, no response needed + log::debug!("Notification accepted"); + } + _ => { + let mut error_body = String::new(); + futures::AsyncReadExt::read_to_string(response.body_mut(), &mut error_body).await?; + + self.error_tx + .send(format!("HTTP {}: {}", response.status(), error_body)) + .await + .map_err(|_| anyhow!("Failed to send error"))?; + } + } + + Ok(()) + } + + /// Set up SSE streaming from the response + async fn setup_sse_stream(&self, mut response: Response) -> Result<()> { + let response_tx = self.response_tx.clone(); + let error_tx = self.error_tx.clone(); + + // Spawn a task to handle the SSE stream + smol::spawn(async move { + let reader = futures::io::BufReader::new(response.body_mut()); + let mut lines = futures::AsyncBufReadExt::lines(reader); + + let mut data_buffer = Vec::new(); + let mut in_message = false; + + while let Some(line_result) = lines.next().await { + match line_result { + Ok(line) => { + if line.is_empty() { + // Empty line signals end of event + if !data_buffer.is_empty() { + let message = data_buffer.join("\n"); + + // Filter out ping messages and empty data + if !message.trim().is_empty() && message != "ping" { + if let Err(e) = response_tx.send(message).await { + log::error!("Failed to send SSE message: {}", e); + break; + } + } + data_buffer.clear(); + } + in_message = false; + } else if let Some(data) = line.strip_prefix("data: ") { + // Handle data lines + let data = data.trim(); + if !data.is_empty() { + // Check if this is a ping message + if data == "ping" { + log::trace!("Received SSE ping"); + continue; + } + data_buffer.push(data.to_string()); + in_message = true; + } + } else if line.starts_with("event:") + || line.starts_with("id:") + || line.starts_with("retry:") + { + // Ignore other SSE fields + continue; + } else if in_message { + // Continuation of data + data_buffer.push(line); + } + } + Err(e) => { + let _ = error_tx.send(format!("SSE stream error: {}", e)).await; + break; + } + } + } + }) + .detach(); + + Ok(()) + } +} + +#[async_trait] +impl Transport for HttpTransport { + async fn send(&self, message: String) -> Result<()> { + self.send_message(message).await + } + + fn receive(&self) -> Pin + Send>> { + Box::pin(self.response_rx.clone()) + } + + fn receive_err(&self) -> Pin + Send>> { + Box::pin(self.error_rx.clone()) + } +} + +impl Drop for HttpTransport { + fn drop(&mut self) { + // Try to cleanup session on drop + let http_client = self.http_client.clone(); + let endpoint = self.endpoint.clone(); + let session_id = self.session_id.lock().clone(); + let headers = self.headers.clone(); + + if let Some(session_id) = session_id { + self.executor + .spawn(async move { + let mut request_builder = Request::builder() + .method(Method::DELETE) + .uri(&endpoint) + .header(HEADER_SESSION_ID, &session_id); + + // Add authentication headers if present + for (key, value) in headers { + request_builder = request_builder.header(key.as_str(), value.as_str()); + } + + let request = request_builder.body(AsyncBody::empty()); + + if let Ok(request) = request { + let _ = http_client.send(request).await; + } + }) + .detach(); + } + } +} diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs index 8b44efdfb196d93df0a609983c2b97147bbe38a8..bb16ab879eac90b7a943b02f5f97dfc004167ea0 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs @@ -990,6 +990,9 @@ impl ExtensionImports for WasmState { command: None, settings: Some(settings), })?), + project::project_settings::ContextServerSettings::Http { .. } => { + bail!("remote context server settings not supported in 0.6.0") + } } } _ => { diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 8316bd7466d113c9211d79afb6e4d1a325e32e52..efc2bbf686a273fe18ca3a34f071176d07532981 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -99,13 +99,18 @@ pub enum ContextServerConfiguration { command: ContextServerCommand, settings: serde_json::Value, }, + Http { + url: url::Url, + headers: HashMap, + }, } 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::Http { .. } => None, } } @@ -142,6 +147,14 @@ impl ContextServerConfiguration { } } } + ContextServerSettings::Http { + enabled: _, + url, + headers: auth, + } => { + let url = url::Url::parse(&url).log_err()?; + Some(ContextServerConfiguration::Http { url, headers: auth }) + } } } } @@ -207,7 +220,7 @@ impl ContextServerStore { #[cfg(any(test, feature = "test-support"))] pub fn test_maintain_server_loop( - context_server_factory: ContextServerFactory, + context_server_factory: Option, registry: Entity, worktree_store: Entity, weak_project: WeakEntity, @@ -215,7 +228,7 @@ impl ContextServerStore { ) -> Self { Self::new_internal( true, - Some(context_server_factory), + context_server_factory, registry, worktree_store, weak_project, @@ -385,17 +398,6 @@ impl ContextServerStore { result } - pub fn restart_server(&mut self, id: &ContextServerId, cx: &mut Context) -> Result<()> { - if let Some(state) = self.servers.get(id) { - let configuration = state.configuration(); - - self.stop_server(&state.server().id(), cx)?; - let new_server = self.create_context_server(id.clone(), configuration.clone(), cx); - self.run_server(new_server, configuration, cx); - } - Ok(()) - } - fn run_server( &mut self, server: Arc, @@ -479,33 +481,42 @@ impl ContextServerStore { id: ContextServerId, configuration: Arc, cx: &mut Context, - ) -> Arc { - let project = self.project.upgrade(); - let mut root_path = None; - if let Some(project) = project { - let project = project.read(cx); - if project.is_local() { - if let Some(path) = project.active_project_directory(cx) { - root_path = Some(path); - } else { - for worktree in self.worktree_store.read(cx).visible_worktrees(cx) { - if let Some(path) = worktree.read(cx).root_dir() { - root_path = Some(path); - break; - } - } - } - } - }; - + ) -> Result> { if let Some(factory) = self.context_server_factory.as_ref() { - factory(id, configuration) - } else { - Arc::new(ContextServer::stdio( + return Ok(factory(id, configuration)); + } + + match configuration.as_ref() { + ContextServerConfiguration::Http { url, headers } => Ok(Arc::new(ContextServer::http( id, - configuration.command().clone(), - root_path, - )) + url, + headers.clone(), + cx.http_client(), + cx.background_executor().clone(), + )?)), + _ => { + 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 + } + }) + }) + }); + Ok(Arc::new(ContextServer::stdio( + id, + configuration.command().unwrap().clone(), + root_path, + ))) + } } } @@ -621,14 +632,16 @@ impl ContextServerStore { let existing_config = state.as_ref().map(|state| state.configuration()); if existing_config.as_deref() != Some(&config) || is_stopped { let config = Arc::new(config); - let server = this.create_context_server(id.clone(), config.clone(), cx); + let server = this.create_context_server(id.clone(), config.clone(), cx)?; servers_to_start.push((server, config)); if this.servers.contains_key(&id) { servers_to_stop.insert(id); } } } - })?; + + anyhow::Ok(()) + })??; this.update(cx, |this, cx| { for id in servers_to_stop { @@ -654,6 +667,7 @@ mod tests { }; use context_server::test::create_fake_transport; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; + use http_client::{FakeHttpClient, Response}; use serde_json::json; use std::{cell::RefCell, path::PathBuf, rc::Rc}; use util::path; @@ -894,12 +908,12 @@ mod tests { }); let store = cx.new(|cx| { ContextServerStore::test_maintain_server_loop( - Box::new(move |id, _| { + Some(Box::new(move |id, _| { 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(), @@ -1130,12 +1144,12 @@ mod tests { let registry = cx.new(|_| ContextServerDescriptorRegistry::new()); let store = cx.new(|cx| { ContextServerStore::test_maintain_server_loop( - Box::new(move |id, _| { + Some(Box::new(move |id, _| { 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(), @@ -1228,6 +1242,73 @@ 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::Http { + enabled: true, + url: server_url.to_string(), + headers: Default::default(), + }, + )], + ) + .await; + + let client = FakeHttpClient::create(|_| async move { + use http_client::AsyncBody; + + let response = Response::builder() + .status(200) + .header("Content-Type", "application/json") + .body(AsyncBody::from( + serde_json::to_string(&json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + } + } + })) + .unwrap(), + )) + .unwrap(); + Ok(response) + }); + cx.update(|cx| cx.set_http_client(client)); + let registry = cx.new(|_| ContextServerDescriptorRegistry::new()); + let store = cx.new(|cx| { + ContextServerStore::test_maintain_server_loop( + None, + 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 10ffc986fa798011c28261d2ff899da66261669a..1bfd44957b2b0d75f8fda2b42a875c92e37d63f4 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -135,6 +135,16 @@ pub enum ContextServerSettings { /// are supported. settings: serde_json::Value, }, + Http { + /// Whether the context server is enabled. + #[serde(default = "default_true")] + enabled: bool, + /// The URL of the remote context server. + url: String, + /// Optional authentication configuration for the remote server. + #[serde(skip_serializing_if = "HashMap::is_empty", default)] + headers: HashMap, + }, } impl From for ContextServerSettings { @@ -146,6 +156,15 @@ impl From for ContextServerSettings { settings::ContextServerSettingsContent::Extension { enabled, settings } => { ContextServerSettings::Extension { enabled, settings } } + settings::ContextServerSettingsContent::Http { + enabled, + url, + headers, + } => ContextServerSettings::Http { + enabled, + url, + headers, + }, } } } @@ -158,6 +177,15 @@ impl Into for ContextServerSettings { ContextServerSettings::Extension { enabled, settings } => { settings::ContextServerSettingsContent::Extension { enabled, settings } } + ContextServerSettings::Http { + enabled, + url, + headers, + } => settings::ContextServerSettingsContent::Http { + enabled, + url, + headers, + }, } } } @@ -174,6 +202,7 @@ impl ContextServerSettings { match self { ContextServerSettings::Custom { enabled, .. } => *enabled, ContextServerSettings::Extension { enabled, .. } => *enabled, + ContextServerSettings::Http { enabled, .. } => *enabled, } } @@ -181,6 +210,7 @@ impl ContextServerSettings { match self { ContextServerSettings::Custom { enabled: e, .. } => *e = enabled, ContextServerSettings::Extension { enabled: e, .. } => *e = enabled, + ContextServerSettings::Http { enabled: e, .. } => *e = enabled, } } } diff --git a/crates/settings/src/settings_content/project.rs b/crates/settings/src/settings_content/project.rs index b6bebd76e28a316f19c400db2877219aeb2c7cc8..6c450bc8384d61acf9d6f894f2ae3de500611618 100644 --- a/crates/settings/src/settings_content/project.rs +++ b/crates/settings/src/settings_content/project.rs @@ -196,7 +196,7 @@ pub struct SessionSettingsContent { } #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom, Debug)] -#[serde(tag = "source", rename_all = "snake_case")] +#[serde(untagged, rename_all = "snake_case")] pub enum ContextServerSettingsContent { Custom { /// Whether the context server is enabled. @@ -206,6 +206,16 @@ pub enum ContextServerSettingsContent { #[serde(flatten)] command: ContextServerCommand, }, + Http { + /// Whether the context server is enabled. + #[serde(default = "default_true")] + enabled: bool, + /// The URL of the remote context server. + url: String, + /// Optional headers to send. + #[serde(skip_serializing_if = "HashMap::is_empty", default)] + headers: HashMap, + }, Extension { /// Whether the context server is enabled. #[serde(default = "default_true")] @@ -217,19 +227,24 @@ pub enum ContextServerSettingsContent { settings: serde_json::Value, }, } + 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::Http { + enabled: remote_enabled, + .. + } => *remote_enabled = enabled, } } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 998d1831a1b5e4179677d33a80fd36718e833511..92b78704163c7852867df8fefc018eaf4135210b 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4657,133 +4657,6 @@ mod tests { }); } - /// Checks that action namespaces are the expected set. The purpose of this is to prevent typos - /// and let you know when introducing a new namespace. - #[gpui::test] - async fn test_action_namespaces(cx: &mut gpui::TestAppContext) { - use itertools::Itertools; - - init_keymap_test(cx); - cx.update(|cx| { - let all_actions = cx.all_action_names(); - - let mut actions_without_namespace = Vec::new(); - let all_namespaces = all_actions - .iter() - .filter_map(|action_name| { - let namespace = action_name - .split("::") - .collect::>() - .into_iter() - .rev() - .skip(1) - .rev() - .join("::"); - if namespace.is_empty() { - actions_without_namespace.push(*action_name); - } - if &namespace == "test_only" || &namespace == "stories" { - None - } else { - Some(namespace) - } - }) - .sorted() - .dedup() - .collect::>(); - assert_eq!(actions_without_namespace, Vec::<&str>::new()); - - let expected_namespaces = vec![ - "action", - "activity_indicator", - "agent", - #[cfg(not(target_os = "macos"))] - "app_menu", - "assistant", - "assistant2", - "auto_update", - "bedrock", - "branches", - "buffer_search", - "channel_modal", - "cli", - "client", - "collab", - "collab_panel", - "command_palette", - "console", - "context_server", - "copilot", - "debug_panel", - "debugger", - "dev", - "diagnostics", - "edit_prediction", - "editor", - "feedback", - "file_finder", - "git", - "git_onboarding", - "git_panel", - "go_to_line", - "icon_theme_selector", - "journal", - "keymap_editor", - "keystroke_input", - "language_selector", - "line_ending_selector", - "lsp_tool", - "markdown", - "menu", - "notebook", - "notification_panel", - "onboarding", - "outline", - "outline_panel", - "pane", - "panel", - "picker", - "project_panel", - "project_search", - "project_symbols", - "projects", - "repl", - "rules_library", - "search", - "settings_editor", - "settings_profile_selector", - "snippets", - "stash_picker", - "supermaven", - "svg", - "syntax_tree_view", - "tab_switcher", - "task", - "terminal", - "terminal_panel", - "theme_selector", - "toast", - "toolchain", - "variable_list", - "vim", - "window", - "workspace", - "zed", - "zed_actions", - "zed_predict_onboarding", - "zeta", - ]; - assert_eq!( - all_namespaces, - expected_namespaces - .into_iter() - .map(|namespace| namespace.to_string()) - .sorted() - .collect::>() - ); - }); - } - #[gpui::test] fn test_bundled_settings_and_themes(cx: &mut App) { cx.text_system() diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md index 8fa36675ec46ed6ae1830dd32196815c34ab587f..d8d2de2a014459ddeed0f2a0fe92c2cbe84045e4 100644 --- a/docs/src/ai/mcp.md +++ b/docs/src/ai/mcp.md @@ -40,11 +40,14 @@ You can connect them by adding their commands directly to your `settings.json`, ```json [settings] { "context_servers": { - "your-mcp-server": { - "source": "custom", + "run-command": { "command": "some-command", "args": ["arg-1", "arg-2"], "env": {} + }, + "over-http": { + "url": "custom", + "headers": { "Authorization": "Bearer " } } } }