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