Add HTTP transport support for MCP servers (#39021)

Artur Shirokov and Conrad Irwin created

### 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 <conrad.irwin@gmail.com>

Change summary

Cargo.lock                                                                |   1 
crates/agent_servers/src/acp.rs                                           |  81 
crates/agent_ui/src/agent_configuration.rs                                |  45 
crates/agent_ui/src/agent_configuration/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 
crates/extension_host/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 
crates/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(-)

Detailed changes

Cargo.lock 🔗

@@ -3690,6 +3690,7 @@ dependencies = [
  "collections",
  "futures 0.3.31",
  "gpui",
+ "http_client",
  "log",
  "net",
  "parking_lot",

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

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();

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<String, String>,
+    },
     Extension {
         id: ContextServerId,
         repository_url: Option<SharedString>,
@@ -47,9 +54,11 @@ enum ConfigurationTarget {
 enum ConfigurationSource {
     New {
         editor: Entity<Editor>,
+        is_http: bool,
     },
     Existing {
         editor: Entity<Editor>,
+        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, String>)>,
+) -> String {
+    let (name, url, headers) = match existing {
+        Some((id, url, headers)) => {
+            let header = if headers.is_empty() {
+                r#"// "Authorization": "Bearer <token>"#.to_string()
+            } else {
+                let json = serde_json::to_string_pretty(&headers).unwrap();
+                let mut lines = json.split("\n").collect::<Vec<_>>();
+                if lines.len() > 1 {
+                    lines.remove(0);
+                    lines.pop();
+                }
+                lines
+                    .into_iter()
+                    .map(|line| format!("  {}", line))
+                    .collect::<String>()
+            };
+            (id.0.to_string(), url, header)
+        }
+        None => (
+            "some-remote-server".to_string(),
+            "https://example.com/mcp".to_string(),
+            r#"// "Authorization": "Bearer <token>"#.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<String, String>)> {
+    #[derive(Deserialize)]
+    struct Temp {
+        url: String,
+        #[serde(default)]
+        headers: HashMap<String, String>,
+    }
+    let value: HashMap<String, Temp> = 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<WorktreeStore>,
@@ -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
                 },

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"] }

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<str>);
@@ -52,6 +57,25 @@ impl ContextServer {
         }
     }
 
+    pub fn http(
+        id: ContextServerId,
+        endpoint: &Url,
+        headers: HashMap<String, String>,
+        http_client: Arc<dyn HttpClient>,
+        executor: gpui::BackgroundExecutor,
+    ) -> Result<Self> {
+        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<dyn crate::transport::Transport>) -> Self {
         Self {
             id,

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]

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<dyn HttpClient>,
+    endpoint: String,
+    session_id: Arc<SyncMutex<Option<String>>>,
+    executor: BackgroundExecutor,
+    response_tx: channel::Sender<String>,
+    response_rx: channel::Receiver<String>,
+    error_tx: channel::Sender<String>,
+    error_rx: channel::Receiver<String>,
+    // Authentication headers to include in requests
+    headers: HashMap<String, String>,
+}
+
+impl HttpTransport {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        endpoint: String,
+        headers: HashMap<String, String>,
+        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<AsyncBody>) -> 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<Box<dyn Stream<Item = String> + Send>> {
+        Box::pin(self.response_rx.clone())
+    }
+
+    fn receive_err(&self) -> Pin<Box<dyn Stream<Item = String> + 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();
+        }
+    }
+}

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")
+                            }
                         }
                     }
                     _ => {

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<String, String>,
+    },
 }
 
 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<ContextServerFactory>,
         registry: Entity<ContextServerDescriptorRegistry>,
         worktree_store: Entity<WorktreeStore>,
         weak_project: WeakEntity<Project>,
@@ -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<Self>) -> 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<ContextServer>,
@@ -479,33 +481,42 @@ impl ContextServerStore {
         id: ContextServerId,
         configuration: Arc<ContextServerConfiguration>,
         cx: &mut Context<Self>,
-    ) -> Arc<ContextServer> {
-        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<Arc<ContextServer>> {
         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<RefCell<usize>>,
         expected_event_count: usize,

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<String, String>,
+    },
 }
 
 impl From<settings::ContextServerSettingsContent> for ContextServerSettings {
@@ -146,6 +156,15 @@ impl From<settings::ContextServerSettingsContent> 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<settings::ContextServerSettingsContent> 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,
         }
     }
 }

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<String, String>,
+    },
     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,
         }
     }
 }

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::<Vec<_>>()
-                        .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::<Vec<_>>();
-            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::<Vec<_>>()
-            );
-        });
-    }
-
     #[gpui::test]
     fn test_bundled_settings_and_themes(cx: &mut App) {
         cx.text_system()

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 <token>" }
     }
   }
 }