Connect to Codex MCP server

Agus Zubiaga created

Change summary

crates/agent_servers/src/agent_servers.rs |   3 
crates/agent_servers/src/claude.rs        |  30 ---
crates/agent_servers/src/codex.rs         | 191 +++++++++++++++++++++++++
crates/agent_servers/src/mcp_server.rs    |  40 +++-
crates/agent_servers/src/settings.rs      |  11 +
crates/agent_ui/src/agent_panel.rs        |   7 
crates/agent_ui/src/agent_ui.rs           |   2 
crates/context_server/src/client.rs       |   1 
8 files changed, 247 insertions(+), 38 deletions(-)

Detailed changes

crates/agent_servers/src/agent_servers.rs 🔗

@@ -1,5 +1,7 @@
 mod claude;
+mod codex;
 mod gemini;
+mod mcp_server;
 mod settings;
 mod stdio_agent_server;
 
@@ -7,6 +9,7 @@ mod stdio_agent_server;
 mod e2e_tests;
 
 pub use claude::*;
+pub use codex::*;
 pub use gemini::*;
 pub use settings::*;
 pub use stdio_agent_server::*;

crates/agent_servers/src/claude.rs 🔗

@@ -1,5 +1,4 @@
-mod mcp_server;
-mod tools;
+pub mod tools;
 
 use collections::HashMap;
 use project::Project;
@@ -27,8 +26,8 @@ use gpui::{App, AppContext, Entity, Task};
 use serde::{Deserialize, Serialize};
 use util::ResultExt;
 
-use crate::claude::mcp_server::ClaudeMcpServer;
 use crate::claude::tools::ClaudeTool;
+use crate::mcp_server::{McpConfig, ZedMcpServer};
 use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
 use acp_thread::{AcpClientDelegate, AcpThread, AgentConnection};
 
@@ -70,11 +69,11 @@ impl AgentServer for ClaudeCode {
             let tool_id_map = Rc::new(RefCell::new(HashMap::default()));
 
             let permission_mcp_server =
-                ClaudeMcpServer::new(delegate_rx, tool_id_map.clone(), cx).await?;
+                ZedMcpServer::new(delegate_rx, tool_id_map.clone(), cx).await?;
 
             let mut mcp_servers = HashMap::default();
             mcp_servers.insert(
-                mcp_server::SERVER_NAME.to_string(),
+                crate::mcp_server::SERVER_NAME.to_string(),
                 permission_mcp_server.server_config()?,
             );
             let mcp_config = McpConfig { mcp_servers };
@@ -112,8 +111,8 @@ impl AgentServer for ClaudeCode {
                         "--permission-prompt-tool",
                         &format!(
                             "mcp__{}__{}",
-                            mcp_server::SERVER_NAME,
-                            mcp_server::PERMISSION_TOOL
+                            crate::mcp_server::SERVER_NAME,
+                            crate::mcp_server::PERMISSION_TOOL
                         ),
                         "--allowedTools",
                         "mcp__zed__Read,mcp__zed__Edit",
@@ -249,7 +248,7 @@ struct ClaudeAgentConnection {
     delegate: AcpClientDelegate,
     outgoing_tx: UnboundedSender<SdkMessage>,
     end_turn_tx: Rc<RefCell<Option<oneshot::Sender<Result<()>>>>>,
-    _mcp_server: Option<ClaudeMcpServer>,
+    _mcp_server: Option<ZedMcpServer>,
     _handler_task: Task<()>,
 }
 
@@ -576,21 +575,6 @@ enum PermissionMode {
     Plan,
 }
 
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct McpConfig {
-    mcp_servers: HashMap<String, McpServerConfig>,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct McpServerConfig {
-    command: String,
-    args: Vec<String>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    env: Option<HashMap<String, String>>,
-}
-
 #[cfg(test)]
 pub(crate) mod tests {
     use super::*;

crates/agent_servers/src/codex.rs 🔗

@@ -0,0 +1,191 @@
+use collections::HashMap;
+use context_server::types::CallToolParams;
+use context_server::types::requests::CallTool;
+use context_server::{ContextServer, ContextServerCommand, ContextServerId};
+use project::Project;
+use settings::SettingsStore;
+use std::cell::RefCell;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::sync::Arc;
+
+use agentic_coding_protocol::{self as acp, AnyAgentRequest, AnyAgentResult, ProtocolVersion};
+use anyhow::{Context, Result, anyhow};
+use futures::future::LocalBoxFuture;
+use futures::{AsyncWriteExt, FutureExt};
+use gpui::{App, AppContext, Entity, Task};
+use serde::{Deserialize, Serialize};
+use util::ResultExt;
+
+use crate::mcp_server::{McpConfig, ZedMcpServer};
+use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
+use acp_thread::{AcpClientDelegate, AcpThread, AgentConnection};
+
+#[derive(Clone)]
+pub struct Codex;
+
+impl AgentServer for Codex {
+    fn name(&self) -> &'static str {
+        "Codex"
+    }
+
+    fn empty_state_headline(&self) -> &'static str {
+        self.name()
+    }
+
+    fn empty_state_message(&self) -> &'static str {
+        ""
+    }
+
+    fn logo(&self) -> ui::IconName {
+        ui::IconName::AiOpenAi
+    }
+
+    fn supports_always_allow(&self) -> bool {
+        false
+    }
+
+    fn new_thread(
+        &self,
+        root_dir: &Path,
+        project: &Entity<Project>,
+        cx: &mut App,
+    ) -> Task<Result<Entity<AcpThread>>> {
+        let project = project.clone();
+        let root_dir = root_dir.to_path_buf();
+        let title = self.name().into();
+        cx.spawn(async move |cx| {
+            let (mut delegate_tx, delegate_rx) = watch::channel(None);
+            let tool_id_map = Rc::new(RefCell::new(HashMap::default()));
+
+            let zed_mcp_server = ZedMcpServer::new(delegate_rx, tool_id_map.clone(), cx).await?;
+
+            let mut mcp_servers = HashMap::default();
+            mcp_servers.insert(
+                crate::mcp_server::SERVER_NAME.to_string(),
+                zed_mcp_server.server_config()?,
+            );
+            let mcp_config = McpConfig { mcp_servers };
+
+            // todo! pass zed mcp server to codex tool
+            let mcp_config_file = tempfile::NamedTempFile::new()?;
+            let (mcp_config_file, _mcp_config_path) = mcp_config_file.into_parts();
+
+            let mut mcp_config_file = smol::fs::File::from(mcp_config_file);
+            mcp_config_file
+                .write_all(serde_json::to_string(&mcp_config)?.as_bytes())
+                .await?;
+            mcp_config_file.flush().await?;
+
+            let settings = cx.read_global(|settings: &SettingsStore, _| {
+                settings.get::<AllAgentServersSettings>(None).codex.clone()
+            })?;
+
+            let Some(command) =
+                AgentServerCommand::resolve("codex", &["mcp"], settings, &project, cx).await
+            else {
+                anyhow::bail!("Failed to find codex binary");
+            };
+
+            let codex_mcp_client: Arc<ContextServer> = ContextServer::stdio(
+                ContextServerId("codex-mcp-server".into()),
+                ContextServerCommand {
+                    // todo! should we change ContextServerCommand to take a PathBuf?
+                    path: command.path.to_string_lossy().to_string(),
+                    args: command.args,
+                    env: command.env,
+                },
+            )
+            .into();
+
+            ContextServer::start(codex_mcp_client.clone(), cx).await?;
+            // todo! stop
+
+            cx.new(|cx| {
+                // todo! handle notifications
+                let delegate = AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async());
+                delegate_tx.send(Some(delegate.clone())).log_err();
+
+                let connection = CodexAgentConnection {
+                    root_dir,
+                    codex_mcp_client,
+                    _zed_mcp_server: zed_mcp_server,
+                };
+
+                acp_thread::AcpThread::new(connection, title, None, project.clone(), cx)
+            })
+        })
+    }
+}
+
+impl AgentConnection for CodexAgentConnection {
+    /// Send a request to the agent and wait for a response.
+    fn request_any(
+        &self,
+        params: AnyAgentRequest,
+    ) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>> {
+        let client = self.codex_mcp_client.client();
+        let root_dir = self.root_dir.clone();
+        async move {
+            let client = client.context("Codex MCP server is not initialized")?;
+
+            match params {
+                // todo: consider sending an empty request so we get the init response?
+                AnyAgentRequest::InitializeParams(_) => Ok(AnyAgentResult::InitializeResponse(
+                    acp::InitializeResponse {
+                        is_authenticated: true,
+                        protocol_version: ProtocolVersion::latest(),
+                    },
+                )),
+                AnyAgentRequest::AuthenticateParams(_) => {
+                    Err(anyhow!("Authentication not supported"))
+                }
+                AnyAgentRequest::SendUserMessageParams(message) => {
+                    client
+                        .request::<CallTool>(CallToolParams {
+                            name: "codex".into(),
+                            arguments: Some(serde_json::to_value(CodexToolCallParam {
+                                prompt: message
+                                    .chunks
+                                    .into_iter()
+                                    .filter_map(|chunk| match chunk {
+                                        acp::UserMessageChunk::Text { text } => Some(text),
+                                        acp::UserMessageChunk::Path { .. } => {
+                                            // todo!
+                                            None
+                                        }
+                                    })
+                                    .collect(),
+                                cwd: root_dir,
+                            })?),
+                            meta: None,
+                        })
+                        .await?;
+
+                    Ok(AnyAgentResult::SendUserMessageResponse(
+                        acp::SendUserMessageResponse,
+                    ))
+                }
+                AnyAgentRequest::CancelSendMessageParams(_) => Ok(
+                    AnyAgentResult::CancelSendMessageResponse(acp::CancelSendMessageResponse),
+                ),
+            }
+        }
+        .boxed_local()
+    }
+}
+
+struct CodexAgentConnection {
+    codex_mcp_client: Arc<context_server::ContextServer>,
+    root_dir: PathBuf,
+    _zed_mcp_server: ZedMcpServer,
+}
+
+/// todo! use types from h2a crate when we have one
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub(crate) struct CodexToolCallParam {
+    pub prompt: String,
+    pub cwd: PathBuf,
+}

crates/agent_servers/src/claude/mcp_server.rs → crates/agent_servers/src/mcp_server.rs 🔗

@@ -4,26 +4,23 @@ use acp_thread::AcpClientDelegate;
 use agentic_coding_protocol::{self as acp, Client, ReadTextFileParams, WriteTextFileParams};
 use anyhow::{Context, Result};
 use collections::HashMap;
-use context_server::{
-    listener::McpServer,
-    types::{
-        CallToolParams, CallToolResponse, Implementation, InitializeParams, InitializeResponse,
-        ListToolsResponse, ProtocolVersion, ServerCapabilities, Tool, ToolAnnotations,
-        ToolResponseContent, ToolsCapabilities, requests,
-    },
+use context_server::types::{
+    CallToolParams, CallToolResponse, Implementation, InitializeParams, InitializeResponse,
+    ListToolsResponse, ProtocolVersion, ServerCapabilities, Tool, ToolAnnotations,
+    ToolResponseContent, ToolsCapabilities, requests,
 };
 use gpui::{App, AsyncApp, Task};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use util::debug_panic;
 
-use crate::claude::{
-    McpServerConfig,
-    tools::{ClaudeTool, EditToolParams, EditToolResponse, ReadToolParams, ReadToolResponse},
+// todo! use shared tool inference?
+use crate::claude::tools::{
+    ClaudeTool, EditToolParams, EditToolResponse, ReadToolParams, ReadToolResponse,
 };
 
-pub struct ClaudeMcpServer {
-    server: McpServer,
+pub struct ZedMcpServer {
+    server: context_server::listener::McpServer,
 }
 
 pub const SERVER_NAME: &str = "zed";
@@ -52,13 +49,13 @@ enum PermissionToolBehavior {
     Deny,
 }
 
-impl ClaudeMcpServer {
+impl ZedMcpServer {
     pub async fn new(
         delegate: watch::Receiver<Option<AcpClientDelegate>>,
         tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
         cx: &AsyncApp,
     ) -> Result<Self> {
-        let mut mcp_server = McpServer::new(cx).await?;
+        let mut mcp_server = context_server::listener::McpServer::new(cx).await?;
         mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
         mcp_server.handle_request::<requests::ListTools>(Self::handle_list_tools);
         mcp_server.handle_request::<requests::CallTool>(move |request, cx| {
@@ -298,3 +295,18 @@ impl ClaudeMcpServer {
         })
     }
 }
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct McpConfig {
+    pub mcp_servers: HashMap<String, McpServerConfig>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct McpServerConfig {
+    command: String,
+    args: Vec<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    env: Option<HashMap<String, String>>,
+}

crates/agent_servers/src/settings.rs 🔗

@@ -13,6 +13,7 @@ pub fn init(cx: &mut App) {
 pub struct AllAgentServersSettings {
     pub gemini: Option<AgentServerSettings>,
     pub claude: Option<AgentServerSettings>,
+    pub codex: Option<AgentServerSettings>,
 }
 
 #[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
@@ -29,13 +30,21 @@ impl settings::Settings for AllAgentServersSettings {
     fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
         let mut settings = AllAgentServersSettings::default();
 
-        for AllAgentServersSettings { gemini, claude } in sources.defaults_and_customizations() {
+        for AllAgentServersSettings {
+            gemini,
+            claude,
+            codex,
+        } in sources.defaults_and_customizations()
+        {
             if gemini.is_some() {
                 settings.gemini = gemini.clone();
             }
             if claude.is_some() {
                 settings.claude = claude.clone();
             }
+            if codex.is_some() {
+                settings.codex = codex.clone();
+            }
         }
 
         Ok(settings)

crates/agent_ui/src/agent_panel.rs 🔗

@@ -1938,6 +1938,13 @@ impl AgentPanel {
                                     }
                                     .boxed_clone(),
                                 )
+                                .action(
+                                    "New Codex Thread",
+                                    NewExternalAgentThread {
+                                        agent: Some(crate::ExternalAgent::Codex),
+                                    }
+                                    .boxed_clone(),
+                                )
                         });
                     menu
                 }))

crates/agent_ui/src/agent_ui.rs 🔗

@@ -147,6 +147,7 @@ enum ExternalAgent {
     #[default]
     Gemini,
     ClaudeCode,
+    Codex,
 }
 
 impl ExternalAgent {
@@ -154,6 +155,7 @@ impl ExternalAgent {
         match self {
             ExternalAgent::Gemini => Rc::new(agent_servers::Gemini),
             ExternalAgent::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
+            ExternalAgent::Codex => Rc::new(agent_servers::Codex),
         }
     }
 }

crates/context_server/src/client.rs 🔗

@@ -243,6 +243,7 @@ impl Client {
                     }
                 }
             } else if let Ok(notification) = serde_json::from_str::<AnyNotification>(&message) {
+                dbg!(&notification);
                 let mut notification_handlers = notification_handlers.lock();
                 if let Some(handler) = notification_handlers.get_mut(notification.method.as_str()) {
                     handler(notification.params.unwrap_or(Value::Null), cx.clone());