fix(mcp-server/acp): expose model selection to Zed

This commit is contained in:
James Peter
2025-09-27 09:01:03 +10:00
parent fee7117cf3
commit 0e39d1fccb
9 changed files with 474 additions and 29 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ codex-apply-patch = { path = "../apply-patch" }
codex-file-search = { path = "../file-search" }
codex-mcp-client = { path = "../mcp-client" }
codex-protocol = { path = "../protocol" }
agent-client-protocol = "0.4.0"
agent-client-protocol = "0.4.3"
dirs = "6"
env-flags = "0.1.1"
eventsource-stream = "0.2.3"
+1 -1
View File
@@ -16,7 +16,7 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
agent-client-protocol = "0.4.2"
agent-client-protocol = { version = "0.4.3", features = ["unstable"] }
codex-arg0 = { workspace = true }
codex-common = { workspace = true, features = ["cli"] }
codex-core = { workspace = true }
+5 -8
View File
@@ -1,6 +1,5 @@
//! Asynchronous worker that executes an ACP tool-call inside a spawned Tokio task.
use std::collections::HashMap;
use std::sync::Arc;
use agent_client_protocol as acp;
@@ -18,20 +17,21 @@ use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::RequestId;
use mcp_types::TextContent;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotification;
use crate::session_store::{SessionEntry, SessionMap};
use serde_json;
pub async fn new_session(
request_id: RequestId,
config: CodexConfig,
outgoing: Arc<OutgoingMessageSender>,
session_map: Arc<Mutex<HashMap<Uuid, Arc<CodexConversation>>>>,
session_map: SessionMap,
conversation_manager: Arc<ConversationManager>,
) -> Option<Uuid> {
let config_for_session = config.clone();
let NewConversation {
conversation_id,
conversation,
@@ -54,12 +54,9 @@ pub async fn new_session(
return None;
}
};
let session_uuid: Uuid = conversation_id.into();
session_map
.lock()
.await
.insert(session_uuid, conversation);
let entry = SessionEntry::new(conversation.clone(), config_for_session);
session_map.lock().await.insert(session_uuid, entry);
Some(session_uuid)
}
@@ -183,6 +183,27 @@ pub(crate) fn create_tool_for_acp_prompt() -> Tool {
}
}
/// Builds a `Tool` definition for the `acp/set_model` tool-call.
pub(crate) fn create_tool_for_acp_set_model() -> Tool {
let input_schema = ToolInputSchema {
r#type: "object".to_string(),
required: Some(vec!["sessionId".to_string(), "modelId".to_string()]),
properties: Some(json!({
"sessionId": {"type": "string"},
"modelId": {"type": "string"}
})),
};
Tool {
name: acp::AGENT_METHOD_NAMES.model_select.to_string(),
title: Some(acp::AGENT_METHOD_NAMES.model_select.to_string()),
input_schema,
output_schema: None,
description: Some("Select a model for an existing ACP Codex session.".to_string()),
annotations: None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpNewSessionToolArgs {
@@ -202,6 +223,15 @@ pub struct AcpPromptToolArgs {
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpSetModelToolArgs {
#[serde(rename = "sessionId")]
pub session_id: acp::SessionId,
#[serde(rename = "modelId")]
pub model_id: acp::ModelId,
}
impl CodexToolCallParam {
/// Returns the initial user prompt to start the Codex conversation and the
/// effective Config object generated from the supplied parameters.
+5 -6
View File
@@ -30,6 +30,7 @@ use crate::exec_approval::handle_exec_approval_request;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotificationMeta;
use crate::patch_approval::handle_patch_approval_request;
use crate::session_store::{SessionEntry, SessionMap};
pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
@@ -42,10 +43,11 @@ pub async fn run_codex_tool_session(
initial_prompt: String,
config: CodexConfig,
outgoing: Arc<OutgoingMessageSender>,
session_map: Arc<Mutex<HashMap<Uuid, Arc<CodexConversation>>>>,
session_map: SessionMap,
conversation_manager: Arc<ConversationManager>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
) {
let config_for_session = config.clone();
let NewConversation {
conversation_id,
conversation,
@@ -66,12 +68,9 @@ pub async fn run_codex_tool_session(
return;
}
};
let session_uuid: Uuid = conversation_id.into();
session_map
.lock()
.await
.insert(session_uuid, conversation.clone());
let entry = SessionEntry::new(conversation.clone(), config_for_session);
session_map.lock().await.insert(session_uuid, entry);
let session_configured_event = Event {
// Use a fake id value for now.
+1
View File
@@ -30,6 +30,7 @@ mod json_to_toml;
pub(crate) mod message_processor;
mod outgoing_message;
mod patch_approval;
mod session_store;
use crate::message_processor::MessageProcessor;
use crate::outgoing_message::OutgoingMessage;
+395 -13
View File
@@ -4,29 +4,32 @@ use std::path::PathBuf;
use crate::codex_message_processor::CodexMessageProcessor;
use crate::codex_tool_config::create_tool_for_acp_new_session;
use crate::codex_tool_config::create_tool_for_acp_prompt;
use crate::codex_tool_config::create_tool_for_acp_set_model;
use crate::codex_tool_config::create_tool_for_codex_tool_call_param;
use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param;
use crate::codex_tool_config::AcpNewSessionToolArgs;
use crate::codex_tool_config::AcpPromptToolArgs;
use crate::codex_tool_config::AcpSetModelToolArgs;
use crate::codex_tool_config::CodexToolCallParam;
use crate::codex_tool_config::CodexToolCallReplyParam;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::{OutgoingMessageSender, OutgoingNotification};
use crate::session_store::SessionMap;
use agent_client_protocol as acp;
use anyhow::anyhow;
use anyhow::Context as _;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::ConversationId;
use codex_common::model_presets::{builtin_model_presets, ModelPreset};
use codex_core::AuthManager;
use codex_core::ConversationManager;
use codex_core::config_types::McpServerConfig;
use codex_core::config_types::ClientTools;
use codex_core::config_types::{ClientTools, McpServerConfig, ReasoningEffort};
use codex_core::config::Config;
use codex_core::default_client::USER_AGENT_SUFFIX;
use codex_core::default_client::get_codex_user_agent_default;
use codex_core::CodexConversation;
use codex_core::model_family::{derive_default_model_family, find_family_for_model};
use codex_core::protocol::Submission;
use codex_core::protocol::Op;
use codex_protocol::mcp_protocol::AuthMode;
@@ -56,7 +59,7 @@ pub(crate) struct MessageProcessor {
initialized: bool,
codex_linux_sandbox_exe: Option<PathBuf>,
conversation_manager: Arc<ConversationManager>,
session_map: Arc<Mutex<HashMap<Uuid, Arc<CodexConversation>>>>,
session_map: SessionMap,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
base_config: Arc<Config>,
}
@@ -84,13 +87,14 @@ impl MessageProcessor {
codex_linux_sandbox_exe.clone(),
config_for_processor,
);
let session_map: SessionMap = Arc::new(Mutex::new(HashMap::new()));
Self {
codex_message_processor,
outgoing,
initialized: false,
codex_linux_sandbox_exe,
conversation_manager,
session_map: Arc::new(Mutex::new(HashMap::new())),
session_map,
running_requests_id_to_codex_uuid: Arc::new(Mutex::new(HashMap::new())),
base_config: config,
}
@@ -170,6 +174,35 @@ impl MessageProcessor {
return;
}
if request.method == acp::AGENT_METHOD_NAMES.model_select {
tracing::info!("handling session/set_model via ACP shim");
if let Some(params) = request.params.clone() {
match serde_json::from_value::<acp::SetSessionModelRequest>(params) {
Ok(model_params) => {
self.handle_session_set_model(request_id, model_params)
.await;
}
Err(err) => {
tracing::warn!("Failed to parse session/set_model params: {err}");
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid session/set_model params: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
} else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "session/set_model requires params".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
return;
}
let mut request = request;
if request.method == mcp_types::InitializeRequest::METHOD {
@@ -481,6 +514,7 @@ impl MessageProcessor {
create_tool_for_codex_tool_call_reply_param(),
create_tool_for_acp_new_session(),
create_tool_for_acp_prompt(),
create_tool_for_acp_set_model(),
],
next_cursor: None,
};
@@ -509,6 +543,9 @@ impl MessageProcessor {
_ if name == acp::AGENT_METHOD_NAMES.session_prompt => {
self.handle_tool_call_acp_prompt(id, arguments).await
}
_ if name == acp::AGENT_METHOD_NAMES.model_select => {
self.handle_tool_call_acp_set_model(id, arguments).await
}
_ => {
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
@@ -673,7 +710,8 @@ impl MessageProcessor {
tokio::spawn(async move {
let codex = {
let map = session_map.lock().await;
map.get(&session_id).cloned()
map.get(&session_id)
.map(|entry| entry.conversation.clone())
};
let Some(codex) = codex else {
@@ -746,8 +784,8 @@ impl MessageProcessor {
tracing::info!("session_id: {session_id}");
// Obtain the Codex conversation from the session map, falling back to the conversation manager.
let codex_arc = if let Some(conv) = self.session_map.lock().await.get(&session_id).cloned() {
conv
let codex_arc = if let Some(entry) = self.session_map.lock().await.get(&session_id) {
entry.conversation.clone()
} else {
match self
.conversation_manager
@@ -823,6 +861,7 @@ impl MessageProcessor {
let outgoing = self.outgoing.clone();
let session_map = self.session_map.clone();
let conversation_manager = self.conversation_manager.clone();
let models_state = session_models_from_config(&config);
task::spawn(async move {
let Some(session_id) = crate::acp_tool_runner::new_session(
@@ -841,6 +880,7 @@ impl MessageProcessor {
let response_struct = acp::NewSessionResponse {
session_id: acp::SessionId(Arc::from(session_id_str.clone())),
modes: Some(default_session_modes()),
models: models_state.clone(),
meta: None,
};
@@ -889,6 +929,7 @@ impl MessageProcessor {
let outgoing = self.outgoing.clone();
let session_map = self.session_map.clone();
let conversation_manager = self.conversation_manager.clone();
let models_state = session_models_from_config(&config);
task::spawn(async move {
let Some(session_id) = crate::acp_tool_runner::new_session(
@@ -906,6 +947,7 @@ impl MessageProcessor {
let response = acp::NewSessionResponse {
session_id: acp::SessionId(Arc::from(session_id.to_string())),
modes: Some(default_session_modes()),
models: models_state.clone(),
meta: None,
};
@@ -959,12 +1001,12 @@ impl MessageProcessor {
}
};
let session = {
let session_entry = {
let map = self.session_map.lock().await;
map.get(&session_uuid).cloned()
};
let Some(session) = session else {
let Some(session_entry) = session_entry else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("unknown session id: {}", acp_session_id),
@@ -974,6 +1016,8 @@ impl MessageProcessor {
return;
};
let session = session_entry.conversation.clone();
let outgoing = self.outgoing.clone();
let requests_codex_map = self.running_requests_id_to_codex_uuid.clone();
let prompt_blocks = params.prompt;
@@ -1016,6 +1060,120 @@ impl MessageProcessor {
});
}
async fn handle_session_set_model(
&self,
request_id: RequestId,
params: acp::SetSessionModelRequest,
) {
let session_string = params.session_id.to_string();
let session_uuid = match Uuid::parse_str(&session_string) {
Ok(id) => id,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid session id: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self
.update_session_model_meta(session_uuid, params.session_id, params.model_id)
.await
{
Ok(meta) => {
let response = acp::SetSessionModelResponse { meta };
self.outgoing.send_response(request_id, response).await;
}
Err(err) => {
self.outgoing.send_error(request_id, err).await;
}
}
}
async fn update_session_model_meta(
&self,
session_uuid: Uuid,
session_id: acp::SessionId,
model_id: acp::ModelId,
) -> Result<Option<serde_json::Value>, JSONRPCErrorError> {
let entry = {
let map = self.session_map.lock().await;
map.get(&session_uuid).cloned()
};
let Some(entry) = entry else {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("unknown session id: {}", session_id),
data: None,
});
};
let mut config_guard = entry.config.lock().await;
let selection = match resolve_model_selection(&model_id, &config_guard) {
Some(selection) => selection,
None => {
let message = format!("unknown model id: {}", model_id.to_string());
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
});
}
};
let changed = apply_model_selection(&mut config_guard, &selection.model, selection.effort);
let models_state = session_models_from_config(&config_guard);
let models_meta_value = models_state
.as_ref()
.and_then(|state| serde_json::to_value(state).ok());
let response_meta = models_meta_value
.clone()
.map(|value| json!({ "models": value }));
let configure_op = if changed {
Some(configure_session_op_from_config(&config_guard))
} else {
None
};
let model_name = selection.model.clone();
drop(config_guard);
if let Some(op) = configure_op {
if let Err(err) = entry.conversation.submit(op).await {
return Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: err.to_string(),
data: None,
});
}
}
if let Some(models_meta) = models_meta_value {
let notification = acp::SessionNotification {
session_id: session_id.clone(),
update: acp::SessionUpdate::AgentMessageChunk {
content: acp::ContentBlock::from(format!("Model set to {model_name}")),
},
meta: Some(json!({ "models": models_meta })),
};
if let Ok(params_value) = serde_json::to_value(notification) {
self.outgoing
.send_notification(OutgoingNotification {
method: acp::CLIENT_METHOD_NAMES.session_update.to_string(),
params: Some(params_value),
})
.await;
}
}
Ok(response_meta)
}
async fn handle_tool_call_acp_prompt(
&self,
request_id: RequestId,
@@ -1041,7 +1199,8 @@ impl MessageProcessor {
let session = {
let map = self.session_map.lock().await;
map.get(&session_id).cloned()
map.get(&session_id)
.map(|entry| entry.conversation.clone())
};
let Some(session) = session else {
@@ -1101,6 +1260,80 @@ impl MessageProcessor {
});
}
async fn handle_tool_call_acp_set_model(
&self,
request_id: RequestId,
arguments: Option<serde_json::Value>,
) {
let args = match arguments
.context("arguments required")
.and_then(|args| serde_json::from_value::<AcpSetModelToolArgs>(args).context("invalid arguments"))
{
Ok(args) => args,
Err(err) => {
tracing::warn!("Failed to parse arguments: {err}");
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Failed to parse arguments: {err}"),
annotations: None,
})],
is_error: Some(true),
structured_content: None,
};
self.outgoing.send_response(request_id, result).await;
return;
}
};
let session_uuid = match Uuid::parse_str(&args.session_id.to_string()) {
Ok(id) => id,
Err(err) => {
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Invalid session id: {err}"),
annotations: None,
})],
is_error: Some(true),
structured_content: None,
};
self.outgoing.send_response(request_id, result).await;
return;
}
};
match self
.update_session_model_meta(session_uuid, args.session_id.clone(), args.model_id.clone())
.await
{
Ok(meta) => {
let structured = serde_json::to_value(acp::SetSessionModelResponse { meta })
.unwrap_or_else(|_| json!({}));
let result = CallToolResult {
content: vec![],
is_error: Some(false),
structured_content: Some(structured),
};
self.outgoing.send_response(request_id, result).await;
}
Err(err) => {
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: err.message.clone(),
annotations: None,
})],
is_error: Some(true),
structured_content: None,
};
self.outgoing
.send_response(request_id, result)
.await;
}
}
}
fn acp_prompt_arguments(
arguments: Option<serde_json::Value>,
) -> anyhow::Result<(Uuid, acp::SessionId, Vec<acp::ContentBlock>)> {
@@ -1136,7 +1369,8 @@ impl MessageProcessor {
let conversation = {
let map = self.session_map.lock().await;
map.get(&session_uuid).cloned()
map.get(&session_uuid)
.map(|entry| entry.conversation.clone())
};
let Some(conversation) = conversation else {
@@ -1238,6 +1472,154 @@ fn convert_mcp_servers(
Ok(map)
}
#[derive(Clone)]
struct ModelSelection {
model: String,
effort: ReasoningEffort,
}
fn session_models_from_config(config: &Config) -> Option<acp::SessionModelState> {
let presets = builtin_model_presets(None);
let mut available_models = Vec::new();
let mut current_model_id: Option<acp::ModelId> = None;
for preset in presets.iter() {
let id = acp::ModelId(Arc::from(preset.id));
let description = if preset.description.is_empty() {
None
} else {
Some(preset.description.to_string())
};
available_models.push(acp::ModelInfo {
model_id: id.clone(),
name: preset.label.to_string(),
description,
meta: None,
});
if preset.model.eq_ignore_ascii_case(&config.model) {
let preset_effort = preset_effort(preset);
if current_model_id.is_none() || preset_effort == config.model_reasoning_effort {
current_model_id = Some(id.clone());
}
}
}
if available_models.is_empty() {
let model_lower = config.model.to_ascii_lowercase();
let id = acp::ModelId(Arc::from(format!("custom::{model_lower}")));
available_models.push(acp::ModelInfo {
model_id: id.clone(),
name: config.model.clone(),
description: Some("Configured via CODEX_HOME/config.toml".to_string()),
meta: None,
});
return Some(acp::SessionModelState {
current_model_id: id,
available_models,
meta: None,
});
}
if current_model_id.is_none() {
let model_lower = config.model.to_ascii_lowercase();
let id = acp::ModelId(Arc::from(format!("custom::{model_lower}")));
available_models.push(acp::ModelInfo {
model_id: id.clone(),
name: config.model.clone(),
description: Some("Configured via CODEX_HOME/config.toml".to_string()),
meta: None,
});
current_model_id = Some(id);
}
Some(acp::SessionModelState {
current_model_id: current_model_id.expect("current model id set"),
available_models,
meta: None,
})
}
fn preset_effort(preset: &ModelPreset) -> ReasoningEffort {
preset
.effort
.map(ReasoningEffort::from)
.unwrap_or(ReasoningEffort::Medium)
}
fn resolve_model_selection(model_id: &acp::ModelId, config: &Config) -> Option<ModelSelection> {
let requested = model_id.to_string();
let requested_lower = requested.to_ascii_lowercase();
for preset in builtin_model_presets(None).iter() {
if preset.id.eq_ignore_ascii_case(&requested)
|| preset.label.eq_ignore_ascii_case(&requested)
|| preset.model.eq_ignore_ascii_case(&requested)
{
return Some(ModelSelection {
model: preset.model.to_string(),
effort: preset_effort(preset),
});
}
}
if let Some(stripped) = requested_lower.strip_prefix("custom::") {
let effort = if stripped.eq_ignore_ascii_case(&config.model) {
config.model_reasoning_effort
} else {
ReasoningEffort::Medium
};
return Some(ModelSelection {
model: stripped.to_string(),
effort,
});
}
if requested_lower == config.model.to_ascii_lowercase() {
return Some(ModelSelection {
model: config.model.clone(),
effort: config.model_reasoning_effort,
});
}
None
}
fn apply_model_selection(config: &mut Config, model: &str, effort: ReasoningEffort) -> bool {
let mut updated = false;
if !config.model.eq_ignore_ascii_case(model) {
config.model = model.to_string();
config.model_family = find_family_for_model(&config.model)
.unwrap_or_else(|| derive_default_model_family(&config.model));
updated = true;
}
if config.model_reasoning_effort != effort {
config.model_reasoning_effort = effort;
updated = true;
}
updated
}
fn configure_session_op_from_config(config: &Config) -> Op {
Op::ConfigureSession {
provider: config.model_provider.clone(),
model: config.model.clone(),
model_reasoning_effort: config.model_reasoning_effort,
model_reasoning_summary: config.model_reasoning_summary,
model_text_verbosity: config.model_text_verbosity,
user_instructions: config.user_instructions.clone(),
base_instructions: config.base_instructions.clone(),
approval_policy: config.approval_policy.clone(),
sandbox_policy: config.sandbox_policy.clone(),
disable_response_storage: config.disable_response_storage,
notify: config.notify.clone(),
cwd: config.cwd.clone(),
resume_path: None,
}
}
fn default_session_modes() -> acp::SessionModeState {
let mode_id = acp::SessionModeId(Arc::from("default".to_string()));
let mode = acp::SessionMode {
+25
View File
@@ -0,0 +1,25 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_core::config::Config;
use codex_core::CodexConversation;
use tokio::sync::Mutex;
use uuid::Uuid;
/// In-memory session entry tracking an active Codex conversation and its config.
#[derive(Clone)]
pub struct SessionEntry {
pub conversation: Arc<CodexConversation>,
pub config: Arc<Mutex<Config>>,
}
impl SessionEntry {
pub fn new(conversation: Arc<CodexConversation>, config: Config) -> Self {
Self {
conversation,
config: Arc::new(Mutex::new(config)),
}
}
}
pub type SessionMap = Arc<Mutex<HashMap<Uuid, SessionEntry>>>;
+11
View File
@@ -26,6 +26,16 @@ Adjust the `command` or `args` only if you pin a different version or use a glob
- The Rust MCP server exposes ACP tools: `session/new`, `session/prompt`, and fast interrupts via `session/cancel`. These are backed by the same conversation manager that powers the TUI, so approvals, confirm guards, and sandbox policies remain intact.
- Streaming `session/update` notifications bridge Codex events into Zed. You get Answer/Reasoning updates, shell command progress, approvals, and apply_patch diffs in the Zed UI without losing terminal parity.
- MCP configuration stays centralized in `CODEX_HOME/config.toml`. Use `[experimental_client_tools]` to delegate file read/write and permission requests back to Zed when you want its UI to handle approvals.
- MCP configuration stays centralized in `CODEX_HOME/config.toml`. Use `[experimental_client_tools]` to delegate file read/write and permission requests back to Zed when you want its UI to handle approvals. A minimal setup looks like:
```toml
[experimental_client_tools]
request_permission = { mcp_server = "zed", tool_name = "requestPermission" }
read_text_file = { mcp_server = "zed", tool_name = "readTextFile" }
write_text_file = { mcp_server = "zed", tool_name = "writeTextFile" }
```
Zed wires these tools automatically when you add the Code agent, so the identifiers above match the defaults.
- The CLI entry point (`npx @just-every/code acp`) is a thin wrapper over the Rust binary (`cargo run -p code-mcp-server -- --stdio`) that ships alongside the rest of Code. Build-from-source workflows plug in by swapping `command` for an absolute path to that binary.
## Tips and troubleshooting
@@ -33,3 +43,4 @@ Adjust the `command` or `args` only if you pin a different version or use a glob
- Need to inspect the handshake? Run Zed’s `dev: open acp logs` command from the Command Palette; the log shows JSON‑RPC requests and Codex replies.
- If prompts hang, make sure no other process is bound to the same MCP port and that your `CODEX_HOME` points to the intended config directory. The ACP server inherits all of Code’s sandbox settings, so restrictive policies (e.g., `approval_policy = "never"`) still apply.
- Zed currently skips history restores and checkpoint UI for third-party agents. Stick to the TUI if you rely on those features; ACP support is still evolving upstream.
- After a session starts, the model selector in Zed lists Code’s built-in presets (e.g., `gpt-5-codex`, `gpt-5` high/medium/low). Picking a new preset updates the running Codex session immediately, so you don’t have to restart the agent to change models.