feat(acp): integrate ACP support and rename MCP server

This commit is contained in:
James Peter
2025-09-22 13:17:51 +10:00
parent 4a227ef091
commit 1eeae7f886
46 changed files with 2045 additions and 1125 deletions
+59 -32
View File
@@ -27,6 +27,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "agent-client-protocol"
version = "0.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4255a06cc2414033d1fe4baf1968bcc8f16d7e5814f272b97779b5806d129142"
dependencies = [
"schemars 1.0.4",
"serde",
"serde_json",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -785,6 +796,36 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21"
[[package]]
name = "code-mcp-server"
version = "0.0.0"
dependencies = [
"agent-client-protocol",
"anyhow",
"assert_cmd",
"codex-arg0",
"codex-common",
"codex-core",
"codex-login",
"codex-protocol",
"codex-version",
"mcp-types",
"mcp_test_support",
"os_info",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"shlex",
"tempfile",
"tokio",
"toml 0.9.5",
"tracing",
"tracing-subscriber",
"uuid",
"wiremock",
]
[[package]]
name = "codex-ansi-escape"
version = "0.0.0"
@@ -805,6 +846,7 @@ dependencies = [
"similar",
"tempfile",
"thiserror 2.0.16",
"tokio",
"tree-sitter",
"tree-sitter-bash",
]
@@ -872,13 +914,13 @@ dependencies = [
"assert_cmd",
"clap",
"clap_complete",
"code-mcp-server",
"codex-arg0",
"codex-chatgpt",
"codex-common",
"codex-core",
"codex-exec",
"codex-login",
"codex-mcp-server",
"codex-protocol",
"codex-protocol-ts",
"codex-tui",
@@ -916,6 +958,7 @@ dependencies = [
name = "codex-core"
version = "0.0.0"
dependencies = [
"agent-client-protocol",
"anyhow",
"askama",
"assert_cmd",
@@ -1089,35 +1132,6 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "codex-mcp-server"
version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"codex-arg0",
"codex-common",
"codex-core",
"codex-login",
"codex-protocol",
"codex-version",
"mcp-types",
"mcp_test_support",
"os_info",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"shlex",
"tempfile",
"tokio",
"toml 0.9.5",
"tracing",
"tracing-subscriber",
"uuid",
"wiremock",
]
[[package]]
name = "codex-mcp-smoke"
version = "0.0.0"
@@ -3353,8 +3367,8 @@ version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"code-mcp-server",
"codex-core",
"codex-mcp-server",
"codex-protocol",
"mcp-types",
"os_info",
@@ -4836,7 +4850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"schemars_derive",
"schemars_derive 0.8.22",
"serde",
"serde_json",
]
@@ -4861,6 +4875,7 @@ checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive 1.0.4",
"serde",
"serde_json",
]
@@ -4877,6 +4892,18 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "schemars_derive"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.104",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
+1
View File
@@ -21,6 +21,7 @@ thiserror = "2.0.16"
tree-sitter = "0.25.9"
tree-sitter-bash = "0.25.0"
once_cell = "1"
tokio = { version = "1", features = ["rt", "rt-multi-thread"] }
[dev-dependencies]
assert_cmd = "2"
File diff suppressed because it is too large Load Diff
+18 -44
View File
@@ -22,7 +22,6 @@
//!
//! The parser below is a little more lenient than the explicit spec and allows for
//! leading/trailing whitespace around patch markers.
use crate::ApplyPatchArgs;
use std::path::Path;
use std::path::PathBuf;
@@ -103,7 +102,7 @@ pub struct UpdateFileChunk {
pub is_end_of_file: bool,
}
pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
pub fn parse_patch(patch: &str) -> Result<Vec<Hunk>, ParseError> {
let mode = if PARSE_IN_STRICT_MODE {
ParseMode::Strict
} else {
@@ -151,7 +150,7 @@ enum ParseMode {
Lenient,
}
fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<Vec<Hunk>, ParseError> {
let lines: Vec<&str> = patch.trim().lines().collect();
let lines: &[&str] = match check_patch_boundaries_strict(&lines) {
Ok(()) => &lines,
@@ -174,12 +173,7 @@ fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, Pars
line_number += hunk_lines;
remaining_lines = &remaining_lines[hunk_lines..]
}
let patch = lines.join("\n");
Ok(ApplyPatchArgs {
hunks,
patch,
workdir: None,
})
Ok(hunks)
}
/// Checks the start and end lines of the patch text for `apply_patch`,
@@ -461,10 +455,8 @@ fn test_parse_patch() {
"*** Begin Patch\n\
*** End Patch",
ParseMode::Strict
)
.unwrap()
.hunks,
Vec::new()
),
Ok(Vec::new())
);
assert_eq!(
parse_patch_text(
@@ -480,10 +472,8 @@ fn test_parse_patch() {
+ return 123\n\
*** End Patch",
ParseMode::Strict
)
.unwrap()
.hunks,
vec![
),
Ok(vec![
AddFile {
path: PathBuf::from("path/add.py"),
contents: "abc\ndef\n".to_string()
@@ -501,7 +491,7 @@ fn test_parse_patch() {
is_end_of_file: false
}]
}
]
])
);
// Update hunk followed by another hunk (Add File).
assert_eq!(
@@ -514,10 +504,8 @@ fn test_parse_patch() {
+content\n\
*** End Patch",
ParseMode::Strict
)
.unwrap()
.hunks,
vec![
),
Ok(vec![
UpdateFile {
path: PathBuf::from("file.py"),
move_path: None,
@@ -532,7 +520,7 @@ fn test_parse_patch() {
path: PathBuf::from("other.py"),
contents: "content\n".to_string()
}
]
])
);
// Update hunk without an explicit @@ header for the first chunk should parse.
@@ -545,10 +533,8 @@ fn test_parse_patch() {
+bar
*** End Patch"#,
ParseMode::Strict
)
.unwrap()
.hunks,
vec![UpdateFile {
),
Ok(vec![UpdateFile {
path: PathBuf::from("file2.py"),
move_path: None,
chunks: vec![UpdateFileChunk {
@@ -557,7 +543,7 @@ fn test_parse_patch() {
new_lines: vec!["import foo".to_string(), "bar".to_string()],
is_end_of_file: false,
}],
}]
}])
);
}
@@ -588,11 +574,7 @@ fn test_parse_patch_lenient() {
);
assert_eq!(
parse_patch_text(&patch_text_in_heredoc, ParseMode::Lenient),
Ok(ApplyPatchArgs {
hunks: expected_patch.clone(),
patch: patch_text.to_string(),
workdir: None,
})
Ok(expected_patch.clone())
);
let patch_text_in_single_quoted_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n");
@@ -602,11 +584,7 @@ fn test_parse_patch_lenient() {
);
assert_eq!(
parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Lenient),
Ok(ApplyPatchArgs {
hunks: expected_patch.clone(),
patch: patch_text.to_string(),
workdir: None,
})
Ok(expected_patch.clone())
);
let patch_text_in_double_quoted_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n");
@@ -616,11 +594,7 @@ fn test_parse_patch_lenient() {
);
assert_eq!(
parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Lenient),
Ok(ApplyPatchArgs {
hunks: expected_patch,
patch: patch_text.to_string(),
workdir: None,
})
Ok(expected_patch.clone())
);
let patch_text_in_mismatched_quotes_heredoc = format!("<<\"EOF'\n{patch_text}\nEOF\n");
@@ -637,7 +611,7 @@ fn test_parse_patch_lenient() {
"<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string();
assert_eq!(
parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Strict),
Err(expected_error)
Err(expected_error.clone())
);
assert_eq!(
parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Lenient),
@@ -1,5 +1,6 @@
use std::io::Read;
use std::io::Write;
use tokio::runtime::Runtime;
pub fn main() -> ! {
let exit_code = run_main();
@@ -48,7 +49,9 @@ pub fn run_main() -> i32 {
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
match crate::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
let fs = crate::StdFileSystem;
let runtime = Runtime::new().expect("failed to create tokio runtime for apply_patch");
match runtime.block_on(crate::apply_patch(&patch_arg, &mut stdout, &mut stderr, &fs)) {
Ok(()) => {
// Flush to ensure output ordering when used in pipelines.
let _ = stdout.flush();
+11 -1
View File
@@ -61,7 +61,17 @@ where
Some(patch_arg) => {
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
match codex_apply_patch::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
let fs = codex_apply_patch::StdFileSystem;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build apply_patch runtime");
match rt.block_on(codex_apply_patch::apply_patch(
&patch_arg,
&mut stdout,
&mut stderr,
&fs,
)) {
Ok(()) => 0,
Err(_) => 1,
}
+1 -1
View File
@@ -24,7 +24,7 @@ codex-common = { path = "../common", features = ["cli"] }
codex-core = { path = "../core" }
codex-exec = { path = "../exec" }
codex-login = { path = "../login" }
codex-mcp-server = { path = "../mcp-server" }
code-mcp-server = { path = "../mcp-server" }
codex-protocol = { path = "../protocol" }
codex-tui = { path = "../tui" }
codex-version = { path = "../codex-version" }
+1 -1
View File
@@ -96,7 +96,7 @@ impl McpCli {
match subcommand {
McpSubcommand::Serve => {
codex_mcp_server::run_main(codex_linux_sandbox_exe, config_overrides).await?;
code_mcp_server::run_main(codex_linux_sandbox_exe, config_overrides).await?;
}
McpSubcommand::List(args) => {
run_list(&config_overrides, args)?;
+1
View File
@@ -22,6 +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.0.13"
dirs = "6"
env-flags = "0.1.1"
eventsource-stream = "0.2.3"
+349
View File
@@ -0,0 +1,349 @@
#![allow(dead_code)]
use agent_client_protocol as acp;
use anyhow::Context as _;
use anyhow::Result;
use codex_apply_patch::FileSystem;
use codex_apply_patch::StdFileSystem;
use mcp_types::CallToolResult;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use uuid::Uuid;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::protocol::FileChange;
use crate::protocol::ReviewDecision;
use crate::util::strip_bash_lc_and_escape;
pub(crate) struct AcpFileSystem<'a> {
session_id: Uuid,
mcp_connection_manager: &'a McpConnectionManager,
tools: &'a acp::ClientTools,
}
impl<'a> AcpFileSystem<'a> {
pub fn new(
session_id: Uuid,
tools: &'a acp::ClientTools,
mcp_connection_manager: &'a McpConnectionManager,
) -> Self {
Self {
session_id,
mcp_connection_manager,
tools,
}
}
async fn read_text_file_impl(
&self,
tool: &acp::McpToolId,
path: &Path,
) -> Result<String> {
let arguments = acp::ReadTextFileArguments {
session_id: acp::SessionId(self.session_id.to_string().into()),
path: path.to_path_buf(),
line: None,
limit: None,
};
let CallToolResult {
structured_content,
is_error,
..
} = self
.mcp_connection_manager
.call_tool(
&tool.mcp_server,
&tool.tool_name,
Some(serde_json::to_value(arguments).unwrap_or_default()),
Some(Duration::from_secs(15)),
)
.await?;
if is_error.unwrap_or_default() {
anyhow::bail!("Error reading text file: {:?}", structured_content);
}
let output = serde_json::from_value::<acp::ReadTextFileOutput>(
structured_content.context("No output from read_text_file tool")?,
)?;
Ok(output.content)
}
async fn write_text_file_impl(
&self,
tool: &acp::McpToolId,
path: &Path,
content: String,
) -> Result<()> {
let arguments = acp::WriteTextFileArguments {
session_id: acp::SessionId(self.session_id.to_string().into()),
path: path.to_path_buf(),
content,
};
let CallToolResult {
structured_content,
is_error,
..
} = self
.mcp_connection_manager
.call_tool(
&tool.mcp_server,
&tool.tool_name,
Some(serde_json::to_value(arguments).unwrap_or_default()),
Some(Duration::from_secs(15)),
)
.await?;
if is_error.unwrap_or_default() {
anyhow::bail!("Error writing text file: {:?}", structured_content);
}
Ok(())
}
}
impl<'a> FileSystem for AcpFileSystem<'a> {
async fn read_text_file(&self, path: &Path) -> std::io::Result<String> {
if let Some(tool) = self.tools.read_text_file.as_ref() {
self.read_text_file_impl(tool, path)
.await
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))
} else {
StdFileSystem.read_text_file(path).await
}
}
async fn write_text_file(&self, path: &Path, contents: String) -> std::io::Result<()> {
if let Some(tool) = self.tools.write_text_file.as_ref() {
self.write_text_file_impl(tool, path, contents)
.await
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))
} else {
StdFileSystem.write_text_file(path, contents).await
}
}
}
pub(crate) async fn request_permission(
permission_tool: &acp::McpToolId,
tool_call: acp::ToolCall,
session_id: Uuid,
mcp_connection_manager: &McpConnectionManager,
) -> Result<ReviewDecision> {
let approve_for_session_id = acp::PermissionOptionId("approve_for_session".into());
let approve_id = acp::PermissionOptionId("approve".into());
let deny_id = acp::PermissionOptionId("deny".into());
let arguments = acp::RequestPermissionArguments {
session_id: acp::SessionId(session_id.to_string().into()),
tool_call,
options: vec![
acp::PermissionOption {
id: approve_for_session_id.clone(),
label: "Approve for Session".into(),
kind: acp::PermissionOptionKind::AllowAlways,
},
acp::PermissionOption {
id: approve_id.clone(),
label: "Approve".into(),
kind: acp::PermissionOptionKind::AllowOnce,
},
acp::PermissionOption {
id: deny_id.clone(),
label: "Deny".into(),
kind: acp::PermissionOptionKind::RejectOnce,
},
],
};
let CallToolResult {
structured_content, ..
} = mcp_connection_manager
.call_tool(
&permission_tool.mcp_server,
&permission_tool.tool_name,
Some(serde_json::to_value(arguments).unwrap_or_default()),
None,
)
.await?;
let result = structured_content.context("No output from permission tool")?;
let result = serde_json::from_value::<acp::RequestPermissionOutput>(result)?;
use acp::RequestPermissionOutcome::*;
let decision = match result.outcome {
Selected { option_id } => {
if option_id == approve_id {
ReviewDecision::Approved
} else if option_id == approve_for_session_id {
ReviewDecision::ApprovedForSession
} else if option_id == deny_id {
ReviewDecision::Denied
} else {
anyhow::bail!("Unexpected permission option: {}", option_id);
}
}
Canceled => ReviewDecision::Abort,
};
Ok(decision)
}
pub fn new_execute_tool_call(
call_id: &str,
command: &[String],
status: acp::ToolCallStatus,
) -> acp::ToolCall {
acp::ToolCall {
id: acp::ToolCallId(call_id.into()),
label: format!("`{}`", strip_bash_lc_and_escape(command)),
kind: acp::ToolKind::Execute,
status,
content: vec![],
locations: vec![],
raw_input: None,
}
}
pub fn new_patch_tool_call(
call_id: &str,
changes: &HashMap<PathBuf, FileChange>,
status: acp::ToolCallStatus,
) -> acp::ToolCall {
let label = if changes.len() == 1
&& let Some((path, change)) = changes.iter().next()
{
let file_name = path.file_name().unwrap_or_default().display().to_string();
match change {
FileChange::Delete => {
return acp::ToolCall {
id: acp::ToolCallId(call_id.into()),
label: format!("Delete “`{file_name}`”"),
kind: acp::ToolKind::Delete,
status,
content: vec![],
locations: vec![],
raw_input: None,
};
}
FileChange::Update {
move_path: Some(new_path),
original_content,
new_content,
..
} if original_content == new_content => {
return acp::ToolCall {
id: acp::ToolCallId(call_id.into()),
label: move_path_label(path, new_path),
kind: acp::ToolKind::Move,
status,
content: vec![],
locations: vec![],
raw_input: None,
};
}
_ => {}
}
format!("Edit “`{file_name}`”")
} else {
format!("Edit {} files", changes.len())
};
let mut locations = Vec::with_capacity(changes.len());
let mut content = Vec::with_capacity(changes.len());
for (path, change) in changes.iter() {
match change {
FileChange::Add { content: new_content } => {
content.push(acp::ToolCallContent::Diff {
diff: acp::Diff {
path: path.clone(),
old_text: None,
new_text: new_content.clone(),
},
});
locations.push(acp::ToolCallLocation {
path: path.clone(),
line: None,
});
}
FileChange::Delete => {
content.push(
format!(
"Delete “`{}`”\n\n",
path.file_name().unwrap_or(path.as_os_str()).display()
)
.into(),
);
}
FileChange::Update {
move_path,
new_content,
original_content,
unified_diff: _,
} => {
if let Some(new_path) = move_path
&& changes.len() > 1
{
content.push(move_path_label(path, new_path).into());
if status == acp::ToolCallStatus::Completed {
locations.push(acp::ToolCallLocation {
path: new_path.clone(),
line: None,
});
} else {
locations.push(acp::ToolCallLocation {
path: path.clone(),
line: None,
});
}
} else {
locations.push(acp::ToolCallLocation {
path: path.clone(),
line: None,
});
}
if original_content != new_content {
content.push(acp::ToolCallContent::Diff {
diff: acp::Diff {
path: path.clone(),
old_text: Some(original_content.clone()),
new_text: new_content.clone(),
},
});
}
}
}
}
acp::ToolCall {
id: acp::ToolCallId(call_id.into()),
label,
kind: acp::ToolKind::Edit,
status,
content,
locations,
raw_input: None,
}
}
fn move_path_label(old: &Path, new: &Path) -> String {
if old.parent() == new.parent() {
let old_name = old.file_name().unwrap_or(old.as_os_str()).display();
let new_name = new.file_name().unwrap_or(new.as_os_str()).display();
format!("Rename “`{old_name}`” to “`{new_name}`”")
} else {
format!("Move “`{}`” to “`{}`”", old.display(), new.display())
}
}
+142 -77
View File
@@ -1,13 +1,20 @@
use anyhow::Context as _;
use anyhow::Result;
use crate::acp::AcpFileSystem;
use crate::codex::Session;
use crate::patch_harness::run_patch_harness;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use crate::protocol::FileChange;
use crate::protocol::ReviewDecision;
use crate::safety::SafetyCheck;
use crate::safety::assess_patch_safety;
use crate::safety::SafetyCheck;
use codex_apply_patch::AffectedPaths;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use codex_apply_patch::FileSystem;
use codex_apply_patch::StdFileSystem;
use codex_apply_patch::print_summary;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use serde_json::json;
use std::collections::HashMap;
use std::path::Path;
@@ -15,31 +22,17 @@ use std::path::PathBuf;
pub const CODEX_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
pub(crate) enum InternalApplyPatchInvocation {
/// The `apply_patch` call was handled programmatically, without any sort
/// of sandbox, because the user explicitly approved it. This is the
/// result to use with the `shell` function call that contained `apply_patch`.
Output(ResponseInputItem),
/// The `apply_patch` call was approved, either automatically because it
/// appears that it should be allowed based on the user's sandbox policy
/// *or* because the user explicitly approved it. In either case, we use
/// exec with [`CODEX_APPLY_PATCH_ARG1`] to realize the `apply_patch` call,
/// but [`ApplyPatchExec::auto_approved`] is used to determine the sandbox
/// used with the `exec()`.
DelegateToExec(ApplyPatchExec),
pub(crate) struct ApplyPatchRun {
pub auto_approved: bool,
pub stdout: String,
pub stderr: String,
pub success: bool,
pub harness_summary_json: Option<String>,
}
pub(crate) struct ApplyPatchExec {
pub(crate) action: ApplyPatchAction,
pub(crate) user_explicitly_approved_this_action: bool,
pub(crate) harness_summary_json: Option<String>,
}
impl From<ResponseInputItem> for InternalApplyPatchInvocation {
fn from(item: ResponseInputItem) -> Self {
InternalApplyPatchInvocation::Output(item)
}
pub(crate) enum ApplyPatchResult {
Applied(ApplyPatchRun),
Reply(ResponseInputItem),
}
pub(crate) async fn apply_patch(
@@ -47,9 +40,7 @@ pub(crate) async fn apply_patch(
sub_id: &str,
call_id: &str,
action: ApplyPatchAction,
) -> InternalApplyPatchInvocation {
// Run the validation harness before assessing safety so we can surface a
// concise status message and capture structured findings for the model.
) -> ApplyPatchResult {
let (harness_summary_json, harness_status_message) = {
let mut summary_json: Option<String> = None;
let mut status_message: Option<String> = None;
@@ -142,59 +133,61 @@ pub(crate) async fn apply_patch(
sess.notify_background_event(sub_id, message.clone()).await;
}
match assess_patch_safety(
let auto_approved = match assess_patch_safety(
&action,
sess.get_approval_policy(),
sess.get_sandbox_policy(),
sess.get_cwd(),
) {
SafetyCheck::AutoApprove { .. } => {
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
action,
user_explicitly_approved_this_action: false,
harness_summary_json: harness_summary_json.clone(),
})
}
SafetyCheck::AutoApprove { .. } => true,
SafetyCheck::AskUser => {
// Compute a readable summary of path changes to include in the
// approval request so the user can make an informed decision.
//
// Note that it might be worth expanding this approval request to
// give the user the option to expand the set of writable roots so
// that similar patches can be auto-approved in the future during
// this session.
let rx_approve = sess
let rx = sess
.request_patch_approval(sub_id.to_owned(), call_id.to_owned(), &action, None, None)
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
action,
user_explicitly_approved_this_action: true,
harness_summary_json,
})
}
match rx.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false,
ReviewDecision::Denied | ReviewDecision::Abort => {
ResponseInputItem::FunctionCallOutput {
return ApplyPatchResult::Reply(ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_owned(),
output: FunctionCallOutputPayload {
content: "patch rejected by user".to_string(),
success: Some(false),
},
}
.into()
});
}
}
}
SafetyCheck::Reject { reason } => ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_owned(),
output: FunctionCallOutputPayload {
content: format!("patch rejected: {reason}"),
success: Some(false),
},
SafetyCheck::Reject { reason } => {
return ApplyPatchResult::Reply(ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_owned(),
output: FunctionCallOutputPayload {
content: format!("patch rejected: {reason}"),
success: Some(false),
},
});
}
.into(),
}
};
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let result = if let Some(client_tools) = sess.client_tools() {
let fs = AcpFileSystem::new(sess.session_uuid(), client_tools, sess.mcp_connection_manager());
apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr, &fs).await
} else {
apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr, &StdFileSystem).await
};
let stdout = String::from_utf8_lossy(&stdout).to_string();
let stderr = String::from_utf8_lossy(&stderr).to_string();
let success = result.is_ok();
ApplyPatchResult::Applied(ApplyPatchRun {
auto_approved,
stdout,
stderr,
success,
harness_summary_json,
})
}
pub(crate) fn convert_apply_patch_to_protocol(
@@ -207,14 +200,17 @@ pub(crate) fn convert_apply_patch_to_protocol(
ApplyPatchFileChange::Add { content } => FileChange::Add {
content: content.clone(),
},
ApplyPatchFileChange::Delete { .. } => FileChange::Delete {},
ApplyPatchFileChange::Delete => FileChange::Delete,
ApplyPatchFileChange::Update {
unified_diff,
move_path,
new_content: _new_content,
original_content,
new_content,
} => FileChange::Update {
unified_diff: unified_diff.clone(),
move_path: move_path.clone(),
original_content: original_content.clone(),
new_content: new_content.clone(),
},
};
result.insert(path.clone(), protocol_change);
@@ -225,19 +221,8 @@ pub(crate) fn convert_apply_patch_to_protocol(
pub(crate) fn get_writable_roots(cwd: &Path) -> Vec<PathBuf> {
let mut writable_roots = Vec::new();
if cfg!(target_os = "macos") {
// On macOS, $TMPDIR is private to the user.
writable_roots.push(std::env::temp_dir());
// Allow pyenv to update its shims directory. Without this, any tool
// that happens to be managed by `pyenv` will fail with an error like:
//
// pyenv: cannot rehash: $HOME/.pyenv/shims isn't writable
//
// which is emitted every time `pyenv` tries to run `rehash` (for
// example, after installing a new Python package that drops an entry
// point). Although the sandbox is intentionally read‑only by default,
// writing to the user's local `pyenv` directory is safe because it
// is already user‑writable and scoped to the current user account.
if let Ok(home_dir) = std::env::var("HOME") {
let pyenv_dir = PathBuf::from(home_dir).join(".pyenv");
writable_roots.push(pyenv_dir);
@@ -248,3 +233,83 @@ pub(crate) fn get_writable_roots(cwd: &Path) -> Vec<PathBuf> {
writable_roots
}
async fn apply_changes_from_apply_patch_and_report(
action: &ApplyPatchAction,
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
fs: &impl FileSystem,
) -> std::io::Result<()> {
match apply_changes_from_apply_patch(action, fs).await {
Ok(affected_paths) => {
print_summary(&affected_paths, stdout)?;
}
Err(err) => {
writeln!(stderr, "{err:#}")?;
}
}
Ok(())
}
async fn apply_changes_from_apply_patch(
action: &ApplyPatchAction,
fs: &impl FileSystem,
) -> Result<AffectedPaths> {
let mut added: Vec<PathBuf> = Vec::new();
let mut modified: Vec<PathBuf> = Vec::new();
let mut deleted: Vec<PathBuf> = Vec::new();
for (path, change) in action.changes() {
match change {
ApplyPatchFileChange::Add { content } => {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create parent directories for {}", path.display())
})?;
}
}
fs.write_text_file(path, content.clone())
.await
.with_context(|| format!("Failed to write file {}", path.display()))?;
added.push(path.clone());
}
ApplyPatchFileChange::Delete => {
std::fs::remove_file(path)
.with_context(|| format!("Failed to delete file {}", path.display()))?;
deleted.push(path.clone());
}
ApplyPatchFileChange::Update {
move_path,
new_content,
..
} => {
if let Some(move_path) = move_path {
if let Some(parent) = move_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create parent directories for {}", move_path.display())
})?;
}
}
std::fs::rename(path, move_path)
.with_context(|| format!("Failed to rename file {}", path.display()))?;
fs.write_text_file(move_path, new_content.clone()).await?;
modified.push(move_path.clone());
deleted.push(path.clone());
} else {
fs.write_text_file(path, new_content.clone()).await?;
modified.push(path.clone());
}
}
}
}
Ok(AffectedPaths {
added,
modified,
deleted,
})
}
+145 -241
View File
@@ -17,9 +17,10 @@ use async_channel::Sender;
use base64::Engine;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::MaybeApplyPatchVerified;
use codex_apply_patch::maybe_parse_apply_patch_verified;
use codex_apply_patch::StdFileSystem;
use codex_browser::BrowserConfig as CodexBrowserConfig;
use codex_browser::BrowserManager;
use agent_client_protocol::ClientTools;
// unused: AuthManager
// unused: ConversationHistoryResponseEvent
use codex_protocol::protocol::TurnAbortReason;
@@ -38,6 +39,7 @@ use tracing::trace;
use tracing::warn;
use uuid::Uuid;
use crate::CodexAuth;
use crate::acp::AcpFileSystem;
use crate::agent_tool::AgentStatusUpdatePayload;
use crate::protocol::ApprovedCommandMatchKind;
use crate::protocol::WebSearchBeginEvent;
@@ -449,12 +451,9 @@ use crate::agent_tool::GetAgentResultParams;
use crate::agent_tool::ListAgentsParams;
use crate::agent_tool::RunAgentParams;
use crate::agent_tool::WaitForAgentParams;
use crate::apply_patch::ApplyPatchExec;
use crate::apply_patch::CODEX_APPLY_PATCH_ARG1;
use crate::apply_patch::InternalApplyPatchInvocation;
use crate::apply_patch::convert_apply_patch_to_protocol;
use crate::apply_patch::get_writable_roots;
use crate::apply_patch::{self};
use crate::apply_patch::{self, ApplyPatchResult};
use crate::client::ModelClient;
use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
@@ -790,6 +789,7 @@ struct BackgroundExecState {
///
/// A session has at most 1 running agent at a time, and can be interrupted by user input.
pub(crate) struct Session {
id: Uuid,
client: ModelClient,
tx_event: Sender<Event>,
@@ -808,6 +808,7 @@ pub(crate) struct Session {
/// Manager for external MCP servers/tools.
mcp_connection_manager: McpConnectionManager,
client_tools: Option<ClientTools>,
#[allow(dead_code)]
session_manager: ExecSessionManager,
@@ -895,6 +896,10 @@ impl Session {
&self.sandbox_policy
}
pub(crate) fn session_uuid(&self) -> Uuid {
self.id
}
pub(crate) fn get_github_config(&self) -> Arc<RwLock<crate::config_types::GithubConfig>> {
Arc::clone(&self.github)
}
@@ -903,6 +908,14 @@ impl Session {
Arc::clone(&self.validation)
}
pub(crate) fn client_tools(&self) -> Option<&ClientTools> {
self.client_tools.as_ref()
}
pub(crate) fn mcp_connection_manager(&self) -> &McpConnectionManager {
&self.mcp_connection_manager
}
pub(crate) fn update_validation_patch_harness(&self, enabled: bool) {
if let Ok(mut cfg) = self.validation.write() {
cfg.patch_harness = enabled;
@@ -938,6 +951,20 @@ impl Session {
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
}
pub(crate) async fn maybe_parse_apply_patch_verified(
&self,
argv: &[String],
cwd: &Path,
) -> MaybeApplyPatchVerified {
if let Some(client_tools) = self.client_tools.as_ref() {
let fs = AcpFileSystem::new(self.id, client_tools, &self.mcp_connection_manager);
codex_apply_patch::maybe_parse_apply_patch_verified(argv, cwd, &fs).await
} else {
let fs = StdFileSystem;
codex_apply_patch::maybe_parse_apply_patch_verified(argv, cwd, &fs).await
}
}
// ────────────────────────────
// Scratchpad helpers
// ────────────────────────────
@@ -2655,8 +2682,25 @@ async fn submission_loop(
// Error messages to dispatch after SessionConfigured is sent.
let mut mcp_connection_errors = Vec::<String>::new();
let mut excluded_tools = HashSet::new();
if let Some(client_tools) = config.experimental_client_tools.as_ref() {
for tool in [
client_tools.request_permission.as_ref(),
client_tools.read_text_file.as_ref(),
client_tools.write_text_file.as_ref(),
]
.into_iter()
.flatten()
{
excluded_tools.insert((
tool.mcp_server.to_string(),
tool.tool_name.to_string(),
));
}
}
let (mcp_connection_manager, failed_clients) =
match McpConnectionManager::new(config.mcp_servers.clone()).await {
match McpConnectionManager::new(config.mcp_servers.clone(), excluded_tools).await {
Ok((mgr, failures)) => (mgr, failures),
Err(e) => {
let message = format!("Failed to create MCP connection manager: {e:#}");
@@ -2690,6 +2734,7 @@ async fn submission_loop(
config.tools_web_search_allowed_domains.clone();
sess = Some(Arc::new(Session {
id: session_id,
client,
tools_config,
tx_event: tx_event.clone(),
@@ -2700,8 +2745,9 @@ async fn submission_loop(
shell_environment_policy: config.shell_environment_policy.clone(),
cwd,
_writable_roots: writable_roots,
mcp_connection_manager,
session_manager: crate::exec_command::ExecSessionManager::default(),
mcp_connection_manager,
client_tools: config.experimental_client_tools.clone(),
session_manager: crate::exec_command::ExecSessionManager::default(),
agents: config.agents.clone(),
notify,
state: Mutex::new(state),
@@ -6427,7 +6473,7 @@ async fn handle_container_exec_with_params(
// If the argv is a shell wrapper, analyze and optionally strip `confirm:`.
let mut params = params;
let mut seq_hint_for_exec = seq_hint;
let seq_hint_for_exec = seq_hint;
if let Some((script_index, script)) = extract_shell_script_from_wrapper(&params.command) {
let trimmed = script.trim_start();
let confirm_prefixes = ["confirm:", "CONFIRM:"];
@@ -6520,148 +6566,6 @@ async fn handle_container_exec_with_params(
}
}
}
// Detect an embedded `apply_patch <<EOF ... EOF` in a larger script and split it out
// so the UI can render a distinct "Updated" block before the "Run" block.
//
// If present, we will:
// 1) Execute the patch as a standalone apply_patch exec (with proper events)
// 2) Remove the statement from the script and continue with the remainder
// (only if the patch succeeded)
if let Ok(Some(found)) = codex_apply_patch::find_embedded_apply_patch(&params.command[script_index]) {
// Build a synthetic minimal script for verified parsing of the patch action,
// preserving an optional cd path for correct path resolution.
let synthetic_script = if let Some(cd) = &found.cd_path {
format!("cd {} && apply_patch <<'EOF'\n{}\nEOF\n", cd, found.patch_body)
} else {
format!("apply_patch <<'EOF'\n{}\nEOF\n", found.patch_body)
};
// Resolve into an ApplyPatchAction using the verified path
let cwd_path = std::path::Path::new(&params.cwd);
let verified = codex_apply_patch::maybe_parse_apply_patch_verified(
&vec!["bash".to_string(), "-lc".to_string(), synthetic_script.clone()],
cwd_path,
);
if let codex_apply_patch::MaybeApplyPatchVerified::Body(action) = verified {
// First, run the patch apply as its own Exec with proper events
let path_to_codex = std::env::current_exe()
.ok()
.map(|p| p.to_string_lossy().to_string());
if let Some(path_to_codex) = path_to_codex {
let patch_params = ExecParams {
command: vec![
path_to_codex,
CODEX_APPLY_PATCH_ARG1.to_string(),
action.patch.clone(),
],
cwd: action.cwd.clone(),
timeout_ms: params.timeout_ms,
env: HashMap::new(),
with_escalated_permissions: params.with_escalated_permissions,
justification: params.justification.clone(),
};
// Safety for patch step mirrors normal patch handling
let safety = assess_safety_for_untrusted_command(
sess.approval_policy,
&sess.sandbox_policy,
patch_params.with_escalated_permissions.unwrap_or(false),
);
let exec_command_context = ExecCommandContext {
sub_id: sub_id.clone(),
call_id: format!("{call_id}.apply_patch"),
command_for_display: vec!["apply_patch".to_string(), action.patch.clone()],
cwd: patch_params.cwd.clone(),
apply_patch: Some(ApplyPatchCommandContext {
user_explicitly_approved_this_action: matches!(safety, SafetyCheck::AutoApprove { .. }),
changes: convert_apply_patch_to_protocol(&action),
}),
};
let patch_result = sess
.run_exec_with_events(
turn_diff_tracker,
exec_command_context,
ExecInvokeArgs {
params: patch_params.clone(),
sandbox_type: match safety { SafetyCheck::AutoApprove { sandbox_type } => sandbox_type, SafetyCheck::AskUser => SandboxType::None, SafetyCheck::Reject { .. } => SandboxType::None },
sandbox_policy: &sess.sandbox_policy,
sandbox_cwd: sess.get_cwd(),
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: None,
},
seq_hint, // occupy the provided sequence range first
output_index,
attempt_req,
)
.await;
let mut should_continue = false;
if let Ok(ref out) = patch_result { should_continue = out.exit_code == 0; }
// If patch step succeeded, strip it from the original script and proceed.
if should_continue {
let (start, end) = found.stmt_byte_range;
let mut residual = String::new();
residual.push_str(&params.command[script_index][..start]);
residual.push_str(&params.command[script_index][end..]);
// Clean leading/trailing separators to avoid syntax errors
let mut residual = residual.trim().to_string();
// Remove leading connectors like &&, ||, ; and trailing ones
let trim_connectors = |s: &str| -> String {
let mut s = s.trim().to_string();
// Leading
for _ in 0..2 {
let st = s.trim_start();
let new = if st.starts_with("&&") { &st[2..] } else if st.starts_with("||") { &st[2..] } else if st.starts_with(';') { &st[1..] } else { st };
s = new.trim_start().to_string();
}
// Trailing
for _ in 0..2 {
let st = s.trim_end();
let new = if st.ends_with("&&") { &st[..st.len()-2] } else if st.ends_with("||") { &st[..st.len()-2] } else if st.ends_with(';') { &st[..st.len()-1] } else { st };
s = new.trim_end().to_string();
}
s
};
residual = trim_connectors(&residual);
if !residual.is_empty() {
params.command[script_index] = residual;
// Bump the seq hint for the following run cell
seq_hint_for_exec = seq_hint.map(|h| h.saturating_add(2));
// Continue with normal flow using updated params (fallthrough below)
// We overwrite `script` in local scope to avoid borrow issues
} else {
// No more commands to run; return the patch result as the tool output
let ok = patch_result
.as_ref()
.map(|o| o.exit_code == 0)
.unwrap_or(false);
let content = match patch_result {
Ok(out) => format_exec_output_with_limit(sess, &sub_id, &call_id, &out),
Err(e) => get_error_message_ui(&e),
};
return ResponseInputItem::FunctionCallOutput { call_id, output: FunctionCallOutputPayload { content, success: Some(ok) } };
}
} else {
// Patch failed; return immediately with patch output (do not run remainder)
let ok = patch_result
.as_ref()
.map(|o| o.exit_code == 0)
.unwrap_or(false);
let content = match patch_result {
Ok(out) => format_exec_output_with_limit(sess, &sub_id, &call_id, &out),
Err(e) => get_error_message_ui(&e),
};
return ResponseInputItem::FunctionCallOutput { call_id, output: FunctionCallOutputPayload { content, success: Some(ok) } };
}
}
}
}
}
strip_leading_confirm_prefix(&mut params.command);
@@ -6801,20 +6705,85 @@ async fn handle_container_exec_with_params(
}
}
// check if this was a patch, and apply it if so
let apply_patch_exec = match maybe_parse_apply_patch_verified(&params.command, &params.cwd) {
MaybeApplyPatchVerified::Body(changes) => {
match apply_patch::apply_patch(sess, &sub_id, &call_id, changes).await {
InternalApplyPatchInvocation::Output(item) => return item,
InternalApplyPatchInvocation::DelegateToExec(apply_patch_exec) => {
Some(apply_patch_exec)
// Check if this was a patch, and apply it in-process if so.
match sess
.maybe_parse_apply_patch_verified(&params.command, &params.cwd)
.await
{
MaybeApplyPatchVerified::Body(action) => {
let changes = convert_apply_patch_to_protocol(&action);
turn_diff_tracker.on_patch_begin(&changes);
match apply_patch::apply_patch(sess, &sub_id, &call_id, action).await {
ApplyPatchResult::Reply(item) => return item,
ApplyPatchResult::Applied(run) => {
let order_begin = crate::protocol::OrderMeta {
request_ordinal: attempt_req,
output_index,
sequence_number: seq_hint,
};
let begin_event = EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: call_id.clone(),
auto_approved: run.auto_approved,
changes,
});
let event = sess.make_event_with_order(&sub_id, begin_event, order_begin, seq_hint);
let _ = sess.tx_event.send(event).await;
let order_end = crate::protocol::OrderMeta {
request_ordinal: attempt_req,
output_index,
sequence_number: seq_hint.map(|h| h.saturating_add(1)),
};
let end_event = EventMsg::PatchApplyEnd(PatchApplyEndEvent {
call_id: call_id.clone(),
stdout: run.stdout.clone(),
stderr: run.stderr.clone(),
success: run.success,
});
let event = sess.make_event_with_order(
&sub_id,
end_event,
order_end,
seq_hint.map(|h| h.saturating_add(1)),
);
let _ = sess.tx_event.send(event).await;
if let Ok(Some(unified_diff)) = turn_diff_tracker.get_unified_diff() {
let diff_event = sess.make_event(
&sub_id,
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }),
);
let _ = sess.tx_event.send(diff_event).await;
}
let mut content = run.stdout;
if !run.success && !run.stderr.is_empty() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(&format!("stderr: {}", run.stderr));
}
if let Some(summary) = run.harness_summary_json {
if !summary.is_empty() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(&summary);
}
}
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content,
success: Some(run.success),
},
};
}
}
}
MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
// It looks like an invocation of `apply_patch`, but we
// could not resolve it into a patch that would apply
// cleanly. Return to model for resample.
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
@@ -6825,78 +6794,22 @@ async fn handle_container_exec_with_params(
}
MaybeApplyPatchVerified::ShellParseError(error) => {
trace!("Failed to parse shell command, {error:?}");
None
}
MaybeApplyPatchVerified::NotApplyPatch => None,
MaybeApplyPatchVerified::NotApplyPatch => {}
}
let safety = {
let state = sess.state.lock().unwrap();
assess_command_safety(
&params.command,
sess.approval_policy,
&sess.sandbox_policy,
&state.approved_commands,
params.with_escalated_permissions.unwrap_or(false),
)
};
let (params, safety, command_for_display) = match &apply_patch_exec {
Some(ApplyPatchExec {
action: ApplyPatchAction { patch, cwd, .. },
user_explicitly_approved_this_action,
..
}) => {
let path_to_codex = std::env::current_exe()
.ok()
.map(|p| p.to_string_lossy().to_string());
let Some(path_to_codex) = path_to_codex else {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "failed to determine path to codex executable".to_string(),
success: None,
},
};
};
let params = ExecParams {
command: vec![
path_to_codex,
CODEX_APPLY_PATCH_ARG1.to_string(),
patch.clone(),
],
cwd: cwd.clone(),
timeout_ms: params.timeout_ms,
env: HashMap::new(),
with_escalated_permissions: params.with_escalated_permissions,
justification: params.justification.clone(),
};
let safety = if *user_explicitly_approved_this_action {
SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
}
} else {
assess_safety_for_untrusted_command(
sess.approval_policy,
&sess.sandbox_policy,
params.with_escalated_permissions.unwrap_or(false),
)
};
(
params,
safety,
vec!["apply_patch".to_string(), patch.clone()],
)
}
None => {
let safety = {
let state = sess.state.lock().unwrap();
assess_command_safety(
&params.command,
sess.approval_policy,
&sess.sandbox_policy,
&state.approved_commands,
params.with_escalated_permissions.unwrap_or(false),
)
};
let command_for_display = params.command.clone();
(params, safety, command_for_display)
}
};
let harness_summary_json = apply_patch_exec
.as_ref()
.and_then(|exec| exec.harness_summary_json.clone());
let command_for_display = params.command.clone();
let harness_summary_json: Option<String> = None;
let sandbox_type = match safety {
SafetyCheck::AutoApprove { sandbox_type } => sandbox_type,
@@ -6951,16 +6864,7 @@ async fn handle_container_exec_with_params(
call_id: call_id.clone(),
command_for_display: command_for_display.clone(),
cwd: params.cwd.clone(),
apply_patch: apply_patch_exec.map(
|ApplyPatchExec {
action,
user_explicitly_approved_this_action,
..
}| ApplyPatchCommandContext {
user_explicitly_approved_this_action,
changes: convert_apply_patch_to_protocol(&action),
},
),
apply_patch: None,
};
let display_label = exec_command_context.command_for_display.join(" ");
+27
View File
@@ -20,6 +20,7 @@ use crate::config_types::ShellEnvironmentPolicyToml;
use crate::config_types::TextVerbosity;
use crate::config_types::Tui;
use crate::config_types::UriBasedFileOpener;
use agent_client_protocol::ClientTools;
use crate::config_types::ConfirmGuardConfig;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::model_family::ModelFamily;
@@ -172,6 +173,9 @@ pub struct Config {
/// Definition for MCP servers that Codex can reach out to for tool calls.
pub mcp_servers: HashMap<String, McpServerConfig>,
/// Optional ACP client tool identifiers supplied by the host IDE.
pub experimental_client_tools: Option<ClientTools>,
/// Configuration for available agent models
pub agents: Vec<AgentConfig>,
@@ -1354,6 +1358,10 @@ pub struct ConfigToml {
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerConfig>,
/// Optional ACP client tool identifiers supplied by the host IDE.
#[serde(default)]
pub experimental_client_tools: Option<ClientTools>,
/// Configuration for available agent models
#[serde(default)]
pub agents: Vec<AgentConfig>,
@@ -1569,6 +1577,8 @@ pub struct ConfigOverrides {
pub show_raw_agent_reasoning: Option<bool>,
pub debug: Option<bool>,
pub tools_web_search_request: Option<bool>,
pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
pub experimental_client_tools: Option<ClientTools>,
}
impl Config {
@@ -1581,6 +1591,8 @@ impl Config {
) -> std::io::Result<Self> {
let user_instructions = Self::load_instructions(Some(&codex_home));
let mut cfg = cfg;
// Destructure ConfigOverrides fully to ensure all overrides are applied.
let ConfigOverrides {
model,
@@ -1599,8 +1611,18 @@ impl Config {
show_raw_agent_reasoning,
debug,
tools_web_search_request: override_tools_web_search_request,
mcp_servers,
experimental_client_tools,
} = overrides;
if let Some(mcp_servers) = mcp_servers {
cfg.mcp_servers = mcp_servers;
}
if let Some(client_tools) = experimental_client_tools {
cfg.experimental_client_tools = Some(client_tools);
}
let (active_profile_name, config_profile) =
match config_profile_key.as_ref().or(cfg.profile.as_ref()) {
Some(key) => {
@@ -1857,6 +1879,7 @@ impl Config {
user_instructions,
base_instructions,
mcp_servers: cfg.mcp_servers,
experimental_client_tools: cfg.experimental_client_tools.clone(),
agents,
model_providers,
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
@@ -2547,6 +2570,7 @@ model_verbosity = "high"
notify: None,
cwd: fixture.cwd(),
mcp_servers: HashMap::new(),
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
codex_home: fixture.codex_home(),
@@ -2616,6 +2640,7 @@ model_verbosity = "high"
notify: None,
cwd: fixture.cwd(),
mcp_servers: HashMap::new(),
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
codex_home: fixture.codex_home(),
@@ -2700,6 +2725,7 @@ model_verbosity = "high"
notify: None,
cwd: fixture.cwd(),
mcp_servers: HashMap::new(),
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
codex_home: fixture.codex_home(),
@@ -2769,6 +2795,7 @@ model_verbosity = "high"
notify: None,
cwd: fixture.cwd(),
mcp_servers: HashMap::new(),
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
codex_home: fixture.codex_home(),
+1
View File
@@ -20,6 +20,7 @@ pub mod config_edit;
pub mod config_profile;
pub mod config_types;
mod conversation_history;
pub mod acp;
pub mod custom_prompts;
pub mod debug_logger;
mod environment_context;
+7 -1
View File
@@ -105,6 +105,7 @@ impl McpConnectionManager {
/// user should be informed about these errors.
pub async fn new(
mcp_servers: HashMap<String, McpServerConfig>,
excluded_tools: HashSet<(String, String)>,
) -> Result<(Self, ClientStartErrors)> {
// Early exit if no servers are configured.
if mcp_servers.is_empty() {
@@ -201,7 +202,7 @@ impl McpConnectionManager {
// Query tools from each server. Do not fail the entire manager if a
// server fails to list tools within its startup timeout; instead,
// record the error and continue.
let (all_tools, list_errors) = list_all_tools(&clients, &per_server_timeout).await;
let (all_tools, list_errors) = list_all_tools(&clients, &per_server_timeout, &excluded_tools).await;
// Remove clients that failed to list tools so they are not used later.
for (server_name, err) in list_errors {
@@ -255,6 +256,7 @@ impl McpConnectionManager {
async fn list_all_tools(
clients: &HashMap<String, std::sync::Arc<McpClient>>,
timeouts: &HashMap<String, Duration>,
excluded_tools: &HashSet<(String, String)>,
) -> (Vec<ToolInfo>, HashMap<String, anyhow::Error>) {
let mut join_set = JoinSet::new();
@@ -283,6 +285,10 @@ async fn list_all_tools(
match join_res {
Ok((server_name, Ok(list_result))) => {
for tool in list_result.tools {
if excluded_tools.contains(&(server_name.clone(), tool.name.clone())) {
continue;
}
let tool_info = ToolInfo {
server_name: server_name.clone(),
tool_name: tool.name.clone(),
+2
View File
@@ -1146,6 +1146,8 @@ pub enum FileChange {
Update {
unified_diff: String,
move_path: Option<PathBuf>,
original_content: String,
new_content: String,
},
}
+2 -2
View File
@@ -564,12 +564,12 @@ mod tests_windows {
bash_exe_fallback: Some(PathBuf::from("bash.exe")),
}),
vec![
"codex-mcp-server.exe",
"code-mcp-server.exe",
"--codex-run-as-apply-patch",
"*** Begin Patch\n*** Update File: C:\\Users\\person\\destination_file.txt\n-original content\n+modified content\n*** End Patch",
],
vec![
"codex-mcp-server.exe",
"code-mcp-server.exe",
"--codex-run-as-apply-patch",
"*** Begin Patch\n*** Update File: C:\\Users\\person\\destination_file.txt\n-original content\n+modified content\n*** End Patch",
],
+12
View File
@@ -545,6 +545,8 @@ index {ZERO_OID}..{right_oid}
FileChange::Update {
unified_diff: "".to_owned(),
move_path: None,
original_content: String::new(),
new_content: String::new(),
},
)]);
acc.on_patch_begin(&update_changes);
@@ -620,6 +622,8 @@ index {left_oid}..{ZERO_OID}
FileChange::Update {
unified_diff: "".to_owned(),
move_path: Some(dest.clone()),
original_content: String::new(),
new_content: String::new(),
},
)]);
acc.on_patch_begin(&mv_changes);
@@ -660,6 +664,8 @@ index {left_oid}..{right_oid}
FileChange::Update {
unified_diff: "".to_owned(),
move_path: Some(dest.clone()),
original_content: String::new(),
new_content: String::new(),
},
)]);
acc.on_patch_begin(&mv_changes);
@@ -682,6 +688,8 @@ index {left_oid}..{right_oid}
FileChange::Update {
unified_diff: "".into(),
move_path: Some(dest.clone()),
original_content: String::new(),
new_content: String::new(),
},
)]);
acc.on_patch_begin(&mv);
@@ -722,6 +730,8 @@ index {ZERO_OID}..{right_oid}
FileChange::Update {
unified_diff: "".to_owned(),
move_path: None,
original_content: String::new(),
new_content: String::new(),
},
)]);
acc.on_patch_begin(&update_a);
@@ -802,6 +812,8 @@ index {left_oid_b}..{ZERO_OID}
FileChange::Update {
unified_diff: "".to_owned(),
move_path: None,
original_content: String::new(),
new_content: String::new(),
},
)]);
acc.on_patch_begin(&update_changes);
+53
View File
@@ -1,6 +1,13 @@
use std::time::Duration;
use std::sync::Arc;
use rand::Rng;
use shlex::try_join;
use tokio::sync::Notify;
use tracing::debug;
use crate::config::Config;
const INITIAL_DELAY_MS: u64 = 200;
const BACKOFF_FACTOR: f64 = 2.0;
@@ -11,3 +18,49 @@ pub(crate) fn backoff(attempt: u64) -> Duration {
let jitter = rand::rng().random_range(0.9..1.1);
Duration::from_millis((base as f64 * jitter) as u64)
}
pub fn escape_command(command: &[String]) -> String {
try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" "))
}
pub fn strip_bash_lc_and_escape(command: &[String]) -> String {
match command {
[first, second, third] if first == "bash" && second == "-lc" => third.clone(),
_ => escape_command(command),
}
}
#[allow(dead_code)]
pub fn notify_on_sigint() -> Arc<Notify> {
let notify = Arc::new(Notify::new());
tokio::spawn({
let notify = Arc::clone(&notify);
async move {
loop {
tokio::signal::ctrl_c().await.ok();
debug!("Keyboard interrupt");
notify.notify_waiters();
}
}
});
notify
}
#[allow(dead_code)]
pub fn is_inside_git_repo(config: &Config) -> bool {
let mut dir = config.cwd.to_path_buf();
loop {
if dir.join(".git").exists() {
return true;
}
if !dir.pop() {
break;
}
}
false
}
+2 -3
View File
@@ -53,7 +53,7 @@ async fn mcp_manager_skips_slow_server_on_timeout() {
servers.insert("slow".to_string(), slow_cfg);
servers.insert("fast".to_string(), fast_cfg);
let (mgr, errs) = McpConnectionManager::new(servers)
let (mgr, errs) = McpConnectionManager::new(servers, std::collections::HashSet::new())
.await
.expect("manager creation should not fail entirely");
@@ -89,7 +89,7 @@ async fn mcp_manager_respects_extended_startup_timeout() {
let mut servers = HashMap::new();
servers.insert("slow_ok".to_string(), slow_ok);
let (mgr, errs) = McpConnectionManager::new(servers)
let (mgr, errs) = McpConnectionManager::new(servers, std::collections::HashSet::new())
.await
.expect("manager creation should not fail");
@@ -97,4 +97,3 @@ async fn mcp_manager_respects_extended_startup_timeout() {
let tools = mgr.list_all_tools();
assert!(tools.keys().any(|k| k.starts_with("slow_ok__")));
}
+1 -2
View File
@@ -3,7 +3,7 @@
This document describes Codex’s experimental MCP interface: a JSON‑RPC API that runs over the Model Context Protocol (MCP) transport to control a local Codex engine.
- Status: experimental and subject to change without notice
- Server binary: `codex mcp` (or `codex-mcp-server`)
- Server binary: `codex mcp` (or `code-mcp-server`)
- Transport: standard MCP over stdio (JSON‑RPC 2.0, line‑delimited)
## Overview
@@ -120,4 +120,3 @@ While processing, the server emits `codex/event` notifications containing agent
## Compatibility and stability
This interface is experimental. Method names, fields, and event shapes may evolve. For the authoritative schema, consult `protocol/src/mcp_protocol.rs` and the corresponding server wiring in `mcp-server/`.
@@ -541,6 +541,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
FileChange::Update {
unified_diff,
move_path,
..
} => {
let header = if let Some(dest) = move_path {
format!(
+2
View File
@@ -165,6 +165,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
debug: None,
show_raw_agent_reasoning: oss.then_some(true),
tools_web_search_request: None,
mcp_servers: None,
experimental_client_tools: None,
};
// Parse `-c` overrides.
let cli_kv_overrides = match config_overrides.parse_overrides() {
+1 -1
View File
@@ -40,4 +40,4 @@ test:
# Run the MCP server
mcp-server-run *args:
cargo run -p codex-mcp-server -- "$@"
cargo run -p code-mcp-server -- "$@"
+2 -2
View File
@@ -3,7 +3,7 @@
//! Example usage:
//!
//! ```bash
//! cargo run -p codex-mcp-client -- `codex-mcp-server`
//! cargo run -p codex-mcp-client -- `code-mcp-server`
//! ```
//!
//! Any additional arguments after the first one are forwarded to the spawned
@@ -41,7 +41,7 @@ async fn main() -> Result<()> {
let mut args: Vec<OsString> = std::env::args_os().skip(1).collect();
if args.is_empty() || args[0] == "--help" || args[0] == "-h" {
eprintln!("Usage: mcp-client <program> [args..]\n\nExample: mcp-client codex-mcp-server");
eprintln!("Usage: mcp-client <program> [args..]\n\nExample: mcp-client code-mcp-server");
std::process::exit(1);
}
let original_args = args.clone();
+4 -3
View File
@@ -1,14 +1,14 @@
[package]
edition = "2024"
name = "codex-mcp-server"
name = "code-mcp-server"
version = { workspace = true }
[[bin]]
name = "codex-mcp-server"
name = "code-mcp-server"
path = "src/main.rs"
[lib]
name = "codex_mcp_server"
name = "code_mcp_server"
path = "src/lib.rs"
[lints]
@@ -16,6 +16,7 @@ workspace = true
[dependencies]
anyhow = "1"
agent-client-protocol = "0.0.13"
codex-arg0 = { path = "../arg0" }
codex-common = { path = "../common", features = ["cli"] }
codex-core = { path = "../core" }
+289
View File
@@ -0,0 +1,289 @@
//! 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;
use agent_client_protocol::ToolCallUpdateFields;
use anyhow::{Context as _, Result};
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config as CodexConfig;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
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 serde_json;
pub async fn new_session(
request_id: RequestId,
config: CodexConfig,
outgoing: Arc<OutgoingMessageSender>,
session_map: Arc<Mutex<HashMap<Uuid, Arc<CodexConversation>>>>,
conversation_manager: Arc<ConversationManager>,
) -> Option<Uuid> {
let NewConversation {
conversation_id,
conversation,
session_configured: _,
} = match conversation_manager.new_conversation(config).await {
Ok(conv) => conv,
Err(err) => {
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: format!("Failed to start Codex session: {err}"),
annotations: None,
})],
is_error: Some(true),
structured_content: None,
};
outgoing
.send_response(request_id, serde_json::to_value(result).unwrap_or_default())
.await;
return None;
}
};
let session_uuid: Uuid = conversation_id.into();
session_map
.lock()
.await
.insert(session_uuid, conversation);
Some(session_uuid)
}
pub async fn prompt(
acp_session_id: acp::SessionId,
codex: Arc<CodexConversation>,
prompt: Vec<acp::ContentBlock>,
outgoing: Arc<OutgoingMessageSender>,
) -> Result<()> {
let items: Vec<InputItem> = prompt
.into_iter()
.filter_map(acp_content_block_to_item)
.collect();
codex
.submit(Op::UserInput { items })
.await
.context("failed to submit prompt to Codex")?;
loop {
let event = codex.next_event().await?;
let acp_update = match event.msg {
EventMsg::Error(error_event) => {
anyhow::bail!("Error: {}", error_event.message);
}
EventMsg::AgentMessage(_) | EventMsg::AgentReasoning(_) => None,
EventMsg::AgentMessageDelta(event) => Some(acp::SessionUpdate::AgentMessageChunk {
content: event.delta.into(),
}),
EventMsg::AgentReasoningDelta(event) => Some(acp::SessionUpdate::AgentThoughtChunk {
content: event.delta.into(),
}),
EventMsg::McpToolCallBegin(event) => {
let invocation = event.invocation.clone();
Some(acp::SessionUpdate::ToolCall(acp::ToolCall {
id: acp::ToolCallId(event.call_id.into()),
label: format!("{}: {}", invocation.server, invocation.tool),
kind: acp::ToolKind::Other,
status: acp::ToolCallStatus::InProgress,
content: vec![],
locations: vec![],
raw_input: invocation.arguments,
}))
}
EventMsg::McpToolCallEnd(event) => {
let call_id = acp::ToolCallId(event.call_id.clone().into());
let content = match event.result.clone() {
Ok(result) => Some(
result
.content
.into_iter()
.map(|block| to_acp_content_block(block).into())
.collect(),
),
Err(err) => Some(vec![err.into()]),
};
Some(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate {
id: call_id,
fields: ToolCallUpdateFields {
status: if event.is_success() {
Some(acp::ToolCallStatus::Completed)
} else {
Some(acp::ToolCallStatus::Failed)
},
content,
..Default::default()
},
}))
}
EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => None,
EventMsg::ExecCommandBegin(event) => Some(acp::SessionUpdate::ToolCall(
codex_core::acp::new_execute_tool_call(
&event.call_id,
&event.command,
acp::ToolCallStatus::InProgress,
),
)),
EventMsg::ExecCommandEnd(event) => {
let call_id = acp::ToolCallId(event.call_id.clone().into());
Some(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate {
id: call_id,
fields: ToolCallUpdateFields {
status: if event.exit_code == 0 {
Some(acp::ToolCallStatus::Completed)
} else {
Some(acp::ToolCallStatus::Failed)
},
content: Some(vec![event.stdout.into(), event.stderr.into()]),
..Default::default()
},
}))
}
EventMsg::PatchApplyBegin(event) => Some(acp::SessionUpdate::ToolCall(
codex_core::acp::new_patch_tool_call(
&event.call_id,
&event.changes,
acp::ToolCallStatus::InProgress,
),
)),
EventMsg::PatchApplyEnd(event) => {
let call_id = acp::ToolCallId(event.call_id.clone().into());
Some(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate {
id: call_id,
fields: ToolCallUpdateFields {
status: if event.success {
Some(acp::ToolCallStatus::Completed)
} else {
Some(acp::ToolCallStatus::Failed)
},
..Default::default()
},
}))
}
EventMsg::TaskComplete(_) => return Ok(()),
EventMsg::SessionConfigured(_)
| EventMsg::TokenCount(_)
| EventMsg::TaskStarted
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::BackgroundEvent(_)
| EventMsg::ShutdownComplete => None,
_ => None,
};
if let Some(update) = acp_update {
let notification = OutgoingNotification {
method: acp::AGENT_METHODS.session_update.to_string(),
params: Some(
serde_json::to_value(acp::SessionNotification {
session_id: acp_session_id.clone(),
update,
})
.unwrap_or_default(),
),
};
outgoing.send_notification(notification).await;
}
}
}
fn acp_content_block_to_item(block: acp::ContentBlock) -> Option<InputItem> {
match block {
acp::ContentBlock::Text(text_content) => Some(InputItem::Text {
text: text_content.text,
}),
acp::ContentBlock::ResourceLink(link) => Some(InputItem::Text {
text: format!("@{}", link.uri),
}),
acp::ContentBlock::Image(image_content) => Some(InputItem::Image {
image_url: image_content.data,
}),
acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => None,
}
}
fn to_acp_annotations(annotations: mcp_types::Annotations) -> acp::Annotations {
acp::Annotations {
audience: annotations.audience.map(|roles| {
roles
.into_iter()
.map(|role| match role {
mcp_types::Role::User => acp::Role::User,
mcp_types::Role::Assistant => acp::Role::Assistant,
})
.collect()
}),
last_modified: annotations.last_modified,
priority: annotations.priority,
}
}
fn to_acp_embedded_resource_resource(
resource: mcp_types::EmbeddedResourceResource,
) -> acp::EmbeddedResourceResource {
match resource {
mcp_types::EmbeddedResourceResource::TextResourceContents(text_contents) => {
acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents {
mime_type: text_contents.mime_type,
text: text_contents.text,
uri: text_contents.uri,
})
}
mcp_types::EmbeddedResourceResource::BlobResourceContents(blob_contents) => {
acp::EmbeddedResourceResource::BlobResourceContents(acp::BlobResourceContents {
blob: blob_contents.blob,
mime_type: blob_contents.mime_type,
uri: blob_contents.uri,
})
}
}
}
fn to_acp_content_block(block: mcp_types::ContentBlock) -> acp::ContentBlock {
match block {
ContentBlock::TextContent(text_content) => acp::ContentBlock::Text(acp::TextContent {
annotations: text_content.annotations.map(to_acp_annotations),
text: text_content.text,
}),
ContentBlock::ImageContent(image_content) => acp::ContentBlock::Image(acp::ImageContent {
annotations: image_content.annotations.map(to_acp_annotations),
data: image_content.data,
mime_type: image_content.mime_type,
}),
ContentBlock::AudioContent(audio_content) => acp::ContentBlock::Audio(acp::AudioContent {
annotations: audio_content.annotations.map(to_acp_annotations),
data: audio_content.data,
mime_type: audio_content.mime_type,
}),
ContentBlock::ResourceLink(resource_link) => {
acp::ContentBlock::ResourceLink(acp::ResourceLink {
annotations: resource_link.annotations.map(to_acp_annotations),
uri: resource_link.uri,
description: resource_link.description,
mime_type: resource_link.mime_type,
name: resource_link.name,
size: resource_link.size,
title: resource_link.title,
})
}
ContentBlock::EmbeddedResource(embedded_resource) => {
acp::ContentBlock::Resource(acp::EmbeddedResource {
annotations: embedded_resource.annotations.map(to_acp_annotations),
resource: to_acp_embedded_resource_resource(embedded_resource.resource),
})
}
}
}
@@ -461,8 +461,18 @@ async fn apply_bespoke_event_handling(
codex_core::protocol::FileChange::Delete => {
codex_protocol::protocol::FileChange::Delete { content: String::new() }
}
codex_core::protocol::FileChange::Update { unified_diff, move_path } => {
codex_protocol::protocol::FileChange::Update { unified_diff, move_path }
codex_core::protocol::FileChange::Update {
unified_diff,
move_path,
original_content,
new_content,
} => {
codex_protocol::protocol::FileChange::Update {
unified_diff,
move_path,
original_content,
new_content,
}
}
};
(p, mapped)
@@ -531,7 +541,7 @@ fn derive_config_from_params(
include_plan_tool,
..
} = params;
let overrides = ConfigOverrides {
let overrides = ConfigOverrides {
model,
review_model: None,
config_profile: profile,
@@ -548,6 +558,8 @@ fn derive_config_from_params(
show_raw_agent_reasoning: None,
debug: None,
tools_web_search_request: None,
mcp_servers: None,
experimental_client_tools: None,
};
let cli_overrides = cli_overrides
@@ -1,9 +1,11 @@
//! Configuration object accepted by the `codex` MCP tool-call.
use agent_client_protocol as acp;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::SandboxMode;
use mcp_types::Tool;
use mcp_types::ToolInputSchema;
use mcp_types::ToolOutputSchema;
use schemars::JsonSchema;
use schemars::r#gen::SchemaSettings;
use serde::Deserialize;
@@ -130,6 +132,50 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool {
}
}
/// Builds a `Tool` definition for the `acp/new_session` tool-call.
pub(crate) fn create_tool_for_acp_new_session() -> Tool {
#[expect(clippy::expect_used)]
let input_schema_value = serde_json::to_value(acp::NewSessionArguments::schema())
.expect("ACP new_session input schema must serialise to JSON");
#[expect(clippy::expect_used)]
let output_schema_value = serde_json::to_value(acp::NewSessionOutput::schema())
.expect("ACP new_session output schema must serialise to JSON");
let input_schema = serde_json::from_value::<ToolInputSchema>(input_schema_value)
.unwrap_or_else(|e| panic!("failed to create Tool input schema: {e}"));
let output_schema = serde_json::from_value::<ToolOutputSchema>(output_schema_value)
.unwrap_or_else(|e| panic!("failed to create Tool output schema: {e}"));
Tool {
name: acp::AGENT_METHODS.new_session.to_string(),
title: Some(acp::AGENT_METHODS.new_session.to_string()),
input_schema,
output_schema: Some(output_schema),
description: Some("Start a Codex session over ACP.".to_string()),
annotations: None,
}
}
/// Builds a `Tool` definition for the `acp/prompt` tool-call.
pub(crate) fn create_tool_for_acp_prompt() -> Tool {
#[expect(clippy::expect_used)]
let input_schema_value = serde_json::to_value(acp::PromptArguments::schema())
.expect("ACP prompt input schema must serialise to JSON");
let input_schema = serde_json::from_value::<ToolInputSchema>(input_schema_value)
.unwrap_or_else(|e| panic!("failed to create Tool input schema: {e}"));
Tool {
name: acp::AGENT_METHODS.prompt.to_string(),
title: Some(acp::AGENT_METHODS.prompt.to_string()),
input_schema,
output_schema: None,
description: Some("Send a prompt to an existing ACP Codex session.".to_string()),
annotations: None,
}
}
impl CodexToolCallParam {
/// Returns the initial user prompt to start the Codex conversation and the
/// effective Config object generated from the supplied parameters.
@@ -167,6 +213,8 @@ impl CodexToolCallParam {
show_raw_agent_reasoning: None,
debug: None,
tools_web_search_request: None,
mcp_servers: None,
experimental_client_tools: None,
};
let cli_overrides = cli_overrides
+8 -1
View File
@@ -42,6 +42,7 @@ pub async fn run_codex_tool_session(
initial_prompt: String,
config: CodexConfig,
outgoing: Arc<OutgoingMessageSender>,
session_map: Arc<Mutex<HashMap<Uuid, Arc<CodexConversation>>>>,
conversation_manager: Arc<ConversationManager>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
) {
@@ -66,6 +67,12 @@ pub async fn run_codex_tool_session(
}
};
let session_uuid: Uuid = conversation_id.into();
session_map
.lock()
.await
.insert(session_uuid, conversation.clone());
let session_configured_event = Event {
// Use a fake id value for now.
id: "".to_string(),
@@ -90,7 +97,7 @@ pub async fn run_codex_tool_session(
running_requests_id_to_codex_uuid
.lock()
.await
.insert(id.clone(), conversation_id.into());
.insert(id.clone(), session_uuid);
let submission = Submission {
id: sub_id.clone(),
op: Op::UserInput {
+1
View File
@@ -20,6 +20,7 @@ use tracing::error;
use tracing::info;
use tracing_subscriber::EnvFilter;
mod acp_tool_runner;
mod codex_message_processor;
mod codex_tool_config;
mod codex_tool_runner;
+1 -1
View File
@@ -1,6 +1,6 @@
use codex_arg0::arg0_dispatch_or_else;
use codex_common::CliConfigOverrides;
use codex_mcp_server::run_main;
use code_mcp_server::run_main;
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
+232 -40
View File
@@ -6,15 +6,22 @@ use crate::codex_tool_config::CodexToolCallParam;
use crate::codex_tool_config::CodexToolCallReplyParam;
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::create_tool_for_acp_new_session;
use crate::codex_tool_config::create_tool_for_acp_prompt;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
use agent_client_protocol as acp;
use anyhow::Context as _;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::ConversationId;
use codex_core::AuthManager;
use codex_core::ConversationManager;
use codex_core::config_types::McpServerConfig;
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::protocol::Submission;
use codex_protocol::mcp_protocol::AuthMode;
use mcp_types::CallToolRequestParams;
@@ -44,6 +51,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>>>>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
}
@@ -75,6 +83,7 @@ impl MessageProcessor {
initialized: false,
codex_linux_sandbox_exe,
conversation_manager,
session_map: Arc::new(Mutex::new(HashMap::new())),
running_requests_id_to_codex_uuid: Arc::new(Mutex::new(HashMap::new())),
}
}
@@ -238,7 +247,7 @@ impl MessageProcessor {
instructions: None,
protocol_version: params.protocol_version.clone(),
server_info: mcp_types::Implementation {
name: "codex-mcp-server".to_string(),
name: "code-mcp-server".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
title: Some("Codex".to_string()),
user_agent: Some(get_codex_user_agent_default()),
@@ -327,6 +336,8 @@ impl MessageProcessor {
tools: vec![
create_tool_for_codex_tool_call_param(),
create_tool_for_codex_tool_call_reply_param(),
create_tool_for_acp_new_session(),
create_tool_for_acp_prompt(),
],
next_cursor: None,
};
@@ -349,6 +360,12 @@ impl MessageProcessor {
self.handle_tool_call_codex_session_reply(id, arguments)
.await
}
_ if name == acp::AGENT_METHODS.new_session => {
self.handle_tool_call_acp_new_session(id, arguments).await
}
_ if name == acp::AGENT_METHODS.prompt => {
self.handle_tool_call_acp_prompt(id, arguments).await
}
_ => {
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
@@ -419,9 +436,10 @@ impl MessageProcessor {
}
};
// Clone outgoing and server to move into async task.
// Clone outgoing and session map to move into async task.
let outgoing = self.outgoing.clone();
let conversation_manager = self.conversation_manager.clone();
let session_map = self.session_map.clone();
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
// Spawn an async task to handle the Codex session so that we do not
@@ -433,6 +451,7 @@ impl MessageProcessor {
initial_prompt,
config,
outgoing,
session_map,
conversation_manager,
running_requests_id_to_codex_uuid,
)
@@ -504,17 +523,17 @@ impl MessageProcessor {
}
};
// Clone outgoing to move into async task.
let outgoing = self.outgoing.clone();
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
let session_map = self.session_map.clone();
let codex = match self
.conversation_manager
.get_conversation(codex_protocol::mcp_protocol::ConversationId::from(session_id))
.await
{
Ok(c) => c,
Err(_) => {
tokio::spawn(async move {
let codex = {
let map = session_map.lock().await;
map.get(&session_id).cloned()
};
let Some(codex) = codex else {
tracing::warn!("Session not found for session_id: {session_id}");
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
@@ -527,27 +546,17 @@ impl MessageProcessor {
};
outgoing.send_response(request_id, result).await;
return;
}
};
};
// Spawn the long-running reply handler.
tokio::spawn({
let codex = codex.clone();
let outgoing = outgoing.clone();
let prompt = prompt.clone();
let running_requests_id_to_codex_uuid = running_requests_id_to_codex_uuid.clone();
async move {
crate::codex_tool_runner::run_codex_tool_session_reply(
codex,
outgoing,
request_id,
prompt,
running_requests_id_to_codex_uuid,
session_id,
)
.await;
}
crate::codex_tool_runner::run_codex_tool_session_reply(
codex,
outgoing,
request_id,
prompt,
running_requests_id_to_codex_uuid,
session_id,
)
.await;
});
}
@@ -593,16 +602,20 @@ impl MessageProcessor {
};
tracing::info!("session_id: {session_id}");
// Obtain the Codex conversation from the server.
let codex_arc = match self
.conversation_manager
.get_conversation(codex_protocol::mcp_protocol::ConversationId::from(session_id))
.await
{
Ok(c) => c,
Err(_) => {
tracing::warn!("Session not found for session_id: {session_id}");
return;
// 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
} else {
match self
.conversation_manager
.get_conversation(ConversationId::from(session_id))
.await
{
Ok(c) => c,
Err(_) => {
tracing::warn!("Session not found for session_id: {session_id}");
return;
}
}
};
@@ -641,6 +654,185 @@ impl MessageProcessor {
);
}
async fn handle_tool_call_acp_new_session(
&self,
request_id: RequestId,
arguments: Option<serde_json::Value>,
) {
let config = match Self::acp_new_session_cfg(arguments) {
Ok(cfg) => cfg,
Err(err) => {
tracing::warn!("Failed to construct new session config: {}", err);
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Failed to construct new session config: {err}"),
annotations: None,
})],
is_error: Some(true),
structured_content: None,
};
self.outgoing.send_response(request_id, result).await;
return;
}
};
let outgoing = self.outgoing.clone();
let session_map = self.session_map.clone();
let conversation_manager = self.conversation_manager.clone();
task::spawn(async move {
let Some(session_id) = crate::acp_tool_runner::new_session(
request_id.clone(),
config,
outgoing.clone(),
session_map,
conversation_manager,
)
.await
else {
return;
};
let acp_response = acp::NewSessionOutput {
session_id: acp::SessionId(session_id.to_string().into()),
};
let response = CallToolResult {
content: vec![],
is_error: None,
structured_content: Some(serde_json::to_value(acp_response).unwrap_or_default()),
};
outgoing.send_response(request_id, response).await;
});
}
fn acp_new_session_cfg(arguments: Option<serde_json::Value>) -> anyhow::Result<Config> {
let arguments = arguments.context("Arguments required")?;
let arguments = serde_json::from_value::<acp::NewSessionArguments>(arguments)?;
let mcp_servers: HashMap<String, McpServerConfig> = arguments
.mcp_servers
.into_iter()
.map(|cfg| {
let env: HashMap<String, String> = cfg
.env
.into_iter()
.map(|var| (var.name, var.value))
.collect();
(
cfg.name,
McpServerConfig {
command: cfg.command.display().to_string(),
args: cfg.args,
env: if env.is_empty() { None } else { Some(env) },
startup_timeout_ms: None,
},
)
})
.collect();
let overrides = codex_core::config::ConfigOverrides {
cwd: Some(arguments.cwd),
mcp_servers: Some(mcp_servers),
experimental_client_tools: Some(arguments.client_tools),
..Default::default()
};
let cfg =
codex_core::config::Config::load_with_cli_overrides(Default::default(), overrides)?;
Ok(cfg)
}
async fn handle_tool_call_acp_prompt(
&self,
request_id: RequestId,
arguments: Option<serde_json::Value>,
) {
let (session_id, acp_session_id, prompt) = match Self::acp_prompt_arguments(arguments) {
Ok(cfg) => cfg,
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 = {
let map = self.session_map.lock().await;
map.get(&session_id).cloned()
};
let Some(session) = session else {
tracing::warn!("Unknown session id: {}", session_id);
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Unknown session id: {session_id}"),
annotations: None,
})],
is_error: Some(true),
structured_content: None,
};
self.outgoing.send_response(request_id, result).await;
return;
};
let outgoing = self.outgoing.clone();
let requests_codex_map = self.running_requests_id_to_codex_uuid.clone();
task::spawn(async move {
requests_codex_map
.lock()
.await
.insert(request_id.clone(), session_id);
let result = crate::acp_tool_runner::prompt(acp_session_id, session, prompt, outgoing.clone()).await;
let result = match result {
Ok(()) => CallToolResult {
content: vec![],
is_error: Some(false),
structured_content: None,
},
Err(err) => CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
annotations: None,
text: err.to_string(),
r#type: "text".to_owned(),
})],
is_error: Some(true),
structured_content: None,
},
};
outgoing.send_response(request_id.clone(), result).await;
requests_codex_map.lock().await.remove(&request_id);
});
}
fn acp_prompt_arguments(
arguments: Option<serde_json::Value>,
) -> anyhow::Result<(Uuid, acp::SessionId, Vec<acp::ContentBlock>)> {
let arguments = arguments.context("Arguments required")?;
let arguments = serde_json::from_value::<acp::PromptArguments>(arguments)?;
let session_id = Uuid::parse_str(&arguments.session_id.0)?;
Ok((session_id, arguments.session_id, arguments.prompt))
}
fn handle_resource_updated(
&self,
params: <mcp_types::ResourceUpdatedNotification as mcp_types::ModelContextProtocolNotification>::Params,
@@ -57,6 +57,9 @@ pub(crate) async fn handle_create_conversation(
disable_response_storage: None,
show_raw_agent_reasoning: None,
debug: None,
tools_web_search_request: None,
mcp_servers: None,
experimental_client_tools: None,
};
let cfg: CodexConfig = match CodexConfig::load_with_cli_overrides(cli_overrides, overrides) {
+1 -1
View File
@@ -10,7 +10,7 @@ path = "lib.rs"
anyhow = "1"
assert_cmd = "2"
codex-core = { path = "../../../core" }
codex-mcp-server = { path = "../.." }
code-mcp-server = { path = "../.." }
codex-protocol = { path = "../../../protocol" }
mcp-types = { path = "../../../mcp-types" }
os_info = "3.12.0"
@@ -11,7 +11,7 @@ use tokio::process::ChildStdout;
use anyhow::Context;
use assert_cmd::prelude::*;
use codex_mcp_server::CodexToolCallParam;
use code_mcp_server::CodexToolCallParam;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
@@ -64,8 +64,8 @@ impl McpProcess {
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
// Use assert_cmd to locate the binary path and then switch to tokio::process::Command
let std_cmd = StdCommand::cargo_bin("codex-mcp-server")
.context("should find binary for codex-mcp-server")?;
let std_cmd = StdCommand::cargo_bin("code-mcp-server")
.context("should find binary for code-mcp-server")?;
let program = std_cmd.get_program().to_owned();
@@ -91,7 +91,7 @@ impl McpProcess {
let mut process = cmd
.kill_on_drop(true)
.spawn()
.context("codex-mcp-server proc should start")?;
.context("code-mcp-server proc should start")?;
let stdin = process
.stdin
.take()
@@ -169,7 +169,7 @@ impl McpProcess {
},
},
"serverInfo": {
"name": "codex-mcp-server",
"name": "code-mcp-server",
"title": "Codex",
"version": "0.0.0",
"user_agent": user_agent
@@ -208,6 +208,27 @@ impl McpProcess {
.await
}
pub async fn send_tool_call(
&mut self,
name: &str,
arguments: Option<serde_json::Value>,
) -> anyhow::Result<i64> {
let params = CallToolRequestParams {
name: name.to_string(),
arguments,
};
self.send_request(
mcp_types::CallToolRequest::METHOD,
Some(serde_json::to_value(params)?),
)
.await
}
pub async fn send_list_tools_request(&mut self) -> anyhow::Result<i64> {
self.send_request(mcp_types::ListToolsRequest::METHOD, None)
.await
}
/// Send a `newConversation` JSON-RPC request.
pub async fn send_new_conversation_request(
&mut self,
@@ -336,7 +357,7 @@ impl McpProcess {
Ok(())
}
async fn read_jsonrpc_message(&mut self) -> anyhow::Result<JSONRPCMessage> {
pub async fn read_jsonrpc_message(&mut self) -> anyhow::Result<JSONRPCMessage> {
let mut line = String::new();
self.stdout.read_line(&mut line).await?;
let message = serde_json::from_str::<JSONRPCMessage>(&line)?;
+151 -6
View File
@@ -1,21 +1,26 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use agent_client_protocol as acp;
use std::env;
use std::path::Path;
use std::path::PathBuf;
use anyhow::Context;
use codex_core::protocol::FileChange;
use codex_core::protocol::ReviewDecision;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_mcp_server::CodexToolCallParam;
use codex_mcp_server::ExecApprovalElicitRequestParams;
use codex_mcp_server::ExecApprovalResponse;
use codex_mcp_server::PatchApprovalElicitRequestParams;
use codex_mcp_server::PatchApprovalResponse;
use code_mcp_server::CodexToolCallParam;
use code_mcp_server::ExecApprovalElicitRequestParams;
use code_mcp_server::ExecApprovalResponse;
use code_mcp_server::PatchApprovalElicitRequestParams;
use code_mcp_server::PatchApprovalResponse;
use mcp_types::CallToolResult;
use mcp_types::ElicitRequest;
use mcp_types::ElicitRequestParamsRequestedSchema;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::ListToolsResult;
use mcp_types::ModelContextProtocolRequest;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
@@ -33,6 +38,46 @@ use mcp_test_support::create_shell_sse_response;
// Allow ample time on slower CI or under load to avoid flakes.
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_tools_list_exposes_acp_entries() {
if let Err(err) = tools_list_exposes_acp_entries().await {
panic!("failure: {err}");
}
}
async fn tools_list_exposes_acp_entries() -> anyhow::Result<()> {
let McpHandle {
mut process,
server: _server,
dir: _dir,
} = create_mcp_process(Vec::new()).await?;
let request_id = process.send_list_tools_request().await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
process.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let list_result: ListToolsResult = serde_json::from_value(response.result)?;
let tool_names: HashSet<&str> = list_result.tools.iter().map(|tool| tool.name.as_str()).collect();
for required in [
"codex",
"codex-reply",
acp::AGENT_METHODS.new_session,
acp::AGENT_METHODS.prompt,
] {
assert!(
tool_names.contains(required),
"expected tools/list to include {required}, got {:?}",
tool_names
);
}
Ok(())
}
/// Test that a shell command that is not on the "trusted" list triggers an
/// elicitation request to the MCP and that sending the approval runs the
/// command, as expected.
@@ -52,6 +97,20 @@ async fn test_shell_command_approval_triggers_elicitation() {
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_acp_prompt_round_trip() {
if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
if let Err(err) = acp_prompt_round_trip().await {
panic!("failure: {err}");
}
}
async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
// Use a simple, untrusted command that creates a file so we can
// observe a side-effect.
@@ -163,6 +222,90 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
Ok(())
}
async fn acp_prompt_round_trip() -> anyhow::Result<()> {
let responses = vec![create_final_assistant_message_sse_response("All done!")?];
let temp_cwd = TempDir::new()?;
let McpHandle {
mut process,
server: _server,
dir: _dir,
} = create_mcp_process(responses).await?;
let new_session_args = acp::NewSessionArguments {
mcp_servers: Vec::new(),
client_tools: acp::ClientTools {
request_permission: None,
write_text_file: None,
read_text_file: None,
},
cwd: temp_cwd.path().to_path_buf(),
};
let new_session_request_id = process
.send_tool_call(
acp::AGENT_METHODS.new_session,
Some(serde_json::to_value(new_session_args)?),
)
.await?;
let new_session_response = timeout(
DEFAULT_READ_TIMEOUT,
process.read_stream_until_response_message(RequestId::Integer(new_session_request_id)),
)
.await??;
let new_session_result: CallToolResult = serde_json::from_value(new_session_response.result)?;
let structured = new_session_result
.structured_content
.context("new_session should return structured content")?;
let new_session_output: acp::NewSessionOutput = serde_json::from_value(structured)?;
let prompt_args = acp::PromptArguments {
session_id: new_session_output.session_id.clone(),
prompt: vec![acp::ContentBlock::Text(acp::TextContent {
annotations: None,
text: "Hello from ACP".to_string(),
})],
};
let prompt_request_id = process
.send_tool_call(
acp::AGENT_METHODS.prompt,
Some(serde_json::to_value(prompt_args)?),
)
.await?;
let mut saw_session_update = false;
loop {
let message = timeout(DEFAULT_READ_TIMEOUT, process.read_jsonrpc_message()).await??;
match message {
JSONRPCMessage::Notification(notification) => {
if notification.method == acp::AGENT_METHODS.session_update {
saw_session_update = true;
}
}
JSONRPCMessage::Request(_) => {
// Codex emits event requests (codex/event). Ignore.
}
JSONRPCMessage::Response(response) => {
if response.id == RequestId::Integer(prompt_request_id) {
let prompt_result: CallToolResult = serde_json::from_value(response.result)?;
assert_eq!(prompt_result.is_error, Some(false));
break;
}
}
JSONRPCMessage::Error(error) => {
anyhow::bail!("unexpected MCP error: {error:?}");
}
}
}
assert!(saw_session_update, "acp/prompt should emit at least one session update notification");
Ok(())
}
fn create_expected_elicitation_request(
elicitation_request_id: RequestId,
command: Vec<String>,
@@ -254,6 +397,8 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
FileChange::Update {
unified_diff: "@@ -1 +1 @@\n-original content\n+modified content\n".to_string(),
move_path: None,
original_content: "original content\n".to_string(),
new_content: "modified content\n".to_string(),
},
);
+1 -2
View File
@@ -64,7 +64,7 @@ async fn main() -> Result<()> {
servers.insert("slow_ok".to_string(), slow_ok);
servers.insert("slow_fail".to_string(), slow_fail);
let (mgr, errs) = McpConnectionManager::new(servers).await?;
let (mgr, errs) = McpConnectionManager::new(servers, std::collections::HashSet::new()).await?;
println!("Errors: {}", errs.len());
for (name, e) in &errs {
println!(" {}: {}", name, e);
@@ -76,4 +76,3 @@ async fn main() -> Result<()> {
}
Ok(())
}
+2
View File
@@ -1293,6 +1293,8 @@ pub enum FileChange {
Update {
unified_diff: String,
move_path: Option<PathBuf>,
original_content: String,
new_content: String,
},
}
+2
View File
@@ -1412,6 +1412,8 @@ impl App<'_> {
FileChange::Update {
unified_diff: "+test\n-test2".to_string(),
move_path: None,
original_content: "test2".to_string(),
new_content: "test".to_string(),
},
),
]),
+2
View File
@@ -6420,6 +6420,8 @@ impl ChatWidget<'_> {
codex_core::protocol::FileChange::Update {
unified_diff: unified.clone(),
move_path: None,
original_content: baseline.clone(),
new_content: current.clone(),
},
);
let detail = create_diff_details_only(&single);
@@ -23,7 +23,7 @@ expression: visible_after
Main Causes
- Static linking style: Each bin (codex, codex-tui, codex-exec,
codex-mcp-server, etc.) statically links its full dependency graph, so common
code-mcp-server, etc.) statically links its full dependency graph, so common
code isn’t shared at runtime across executables.
- Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors,
and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui,
+10
View File
@@ -259,6 +259,7 @@ pub(super) fn create_diff_summary_with_width(
FileChange::Update {
unified_diff,
move_path,
..
} => {
let (added, removed) = count_from_unified(unified_diff);
let display_path = if let Some(new_path) = move_path {
@@ -415,6 +416,7 @@ fn render_patch_details_with_width(
FileChange::Update {
unified_diff,
move_path: _,
..
} => {
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
let mut is_first_hunk = true;
@@ -730,6 +732,8 @@ mod tests {
FileChange::Update {
unified_diff: patch,
move_path: Some(PathBuf::from("src/lib_new.rs")),
original_content: original.to_string(),
new_content: modified.to_string(),
},
);
@@ -771,6 +775,8 @@ mod tests {
FileChange::Update {
unified_diff: patch,
move_path: None,
original_content: original.to_string(),
new_content: modified.to_string(),
},
);
@@ -793,6 +799,8 @@ mod tests {
FileChange::Update {
unified_diff: patch,
move_path: None,
original_content: original.to_string(),
new_content: modified.to_string(),
},
);
@@ -816,6 +824,8 @@ mod tests {
FileChange::Update {
unified_diff: patch,
move_path: None,
original_content: original.to_string(),
new_content: modified.to_string(),
},
);
+2
View File
@@ -154,6 +154,8 @@ pub async fn run_main(
debug: Some(cli.debug),
// Enable web search by default (no CLI flag).
tools_web_search_request: Some(true),
mcp_servers: None,
experimental_client_tools: None,
};
// Parse `-c` overrides from the CLI.
+60
View File
@@ -0,0 +1,60 @@
# Using Code with Zed via ACP
The Rust MCP server now exposes the Agent Client Protocol (ACP) tools that Zed expects. This section walks through a minimal setup.
## 1. Configure Code's MCP server
Update your `CODEX_HOME/config.toml` (defaults to `~/.code/config.toml`) so Code knows which "client tools" Zed will handle and how to launch Zed's MCP endpoint:
```toml
[experimental_client_tools]
request_permission = { mcp_server = "zed", tool_name = "permission/request" }
read_text_file = { mcp_server = "zed", tool_name = "fs/read_text_file" }
write_text_file = { mcp_server = "zed", tool_name = "fs/write_text_file" }
[mcp_servers.zed]
command = "/Applications/Zed.app/Contents/MacOS/zed" # adjust for your OS
args = ["mcp", "--stdio"]
env = {}
```
Any existing MCP servers can remain in this table; the exiting overrides simply add Zed as another entry.
## 2. Launch the MCP server under the new name
Build the workspace and run:
```bash
cargo run -p code-mcp-server -- --stdio
```
The server will advertise four tools during the handshake: `codex`, `codex-reply`, `acp/new_session`, and `acp/prompt`.
## 3. Point Zed at Code's MCP endpoint
Add an entry to Zed's MCP configuration (for example, `~/.config/zed/mcp.json`):
```json
{
"servers": {
"code": {
"command": "/path/to/code-mcp-server",
"args": ["--stdio"],
"env": {
"CODEX_HOME": "/Users/you/.code" ,
"RUST_LOG": "info"
}
}
}
}
```
When Zed starts the server it will discover the ACP tools, call `acp/new_session` to create a Codex conversation, and use `acp/prompt` for additional turns. Code streams `acp/session_update` notifications back to Zed, including exec/patch events and approval requests.
## 4. Permission flow summary
- File reads/writes go through the MCP tools you listed under `experimental_client_tools`.
- Explicit approvals (for shell commands or apply_patch) are raised via `permission/request`.
- Code retains confirm guards, sandbox policies, and validation harnesses; the server simply forwards the resulting events over ACP.
Once configured, Zed users can work with Code without any terminal interaction beyond starting `code-mcp-server`.
+120
View File
@@ -0,0 +1,120 @@
# Upstream ACP Integration – Design Notes
## Context
Upstream PR [openai/codex#1707](https://github.com/openai/codex/pull/1707) introduces an
experimental Agent Client Protocol (ACP) bridge so Zed can drive Codex via new
`acp/new_session` and `acp/prompt` MCP tools. The branch (fetched locally as
`upstream-pr-1707`) rewrites large portions of `codex-rs/core`, the MCP server,
and the TypeScript CLI/TUI to accommodate the new workflow.
### Current Status (2025-09-22)
- **Core** – Apply-patch execution now runs in-process with the ACP filesystem
shim, preserving Code’s validation harness and approval prompts while
emitting ACP tool-call metadata for downstream consumers.
- **MCP Server** – `acp/new_session` and `acp/prompt` are available alongside
existing Codex tools. The new `acp_tool_runner` bridges Codex events to ACP
`SessionUpdate`s and reuses the existing conversation manager to track live
sessions.
- **Configuration** – `ConfigOverrides` and the TOML schema understand
`experimental_client_tools` plus inline MCP server definitions, allowing IDE
clients to announce their ACP capabilities without dropping legacy settings.
- **Validation** – `./build-fast.sh` passes after the integration, and new MCP
tests cover ACP list-tool discovery plus a prompt roundtrip.
Code diverges substantially from upstream in these same areas: we retain the
Rust TUI, trimmed CLI scripts, and extended core features such as confirm
guards, queued user input, plan tool updates, and sandbox policies tuned for our
workflow. Directly rebasing onto upstream would drop or regress many of these
capabilities.
## What Upstream Added
- **Core (`codex-rs/core`)**
- New `acp.rs` module with helpers for translating Codex exec/patch events
into ACP `ToolCall` updates, including an ACP-backed `FileSystem` shim and a
permission-request flow (`acp::request_permission`).
- `codex.rs` rewritten around `agent_client_protocol`, removing many existing
operations (`QueueUserInput`, `RegisterApprovedCommand`, validation toggles,
etc.) and introducing new event wiring for ACP tool calls.
- `protocol.rs` pared down to match the simplified upstream surface (fewer
`Op` variants, different `AskForApproval` options, new MCP call events).
- **MCP server (`codex-rs/mcp-server`)**
- New `acp_tool_runner.rs` that spawns Codex sessions on demand, relays MCP
notifications, and surfaces ACP updates.
- `message_processor.rs` extended to expose `acp/new_session` and
`acp/prompt`, and to translate Codex events into ACP notifications.
- **CLI/TUI (Node + Rust)**
- TypeScript terminal UI completely replaced with ACP-first experience.
- Rust TUI and associated tests removed.
## Code-Specific Functionality We Must Preserve
- **Protocol & Ops** – `QueueUserInput`, validation toggles, confirm guards,
rich approval semantics (`ReviewDecision::ApprovedForSession`), and sandbox
policy options currently in `codex-rs/core/src/protocol.rs:1`.
- **Execution Safety & Logging** – Confirm-guard enforcement and the richer
`EventMsg` variants emitted from our `codex-rs/core/src/codex.rs:1`.
- **TUI** – Entire Rust TUI stack (`codex-rs/tui/src/chatwidget.rs:1`,
`codex-rs/tui/src/history_cell.rs:1`, etc.) must remain functional and ignore
unknown ACP events gracefully.
- **CLI Footprint** – Our trimmed `codex-cli` structure and scripts differ from
upstream’s wholesale replacement; we will not adopt the TypeScript overhaul.
- **Config Schema** – Existing TOML fields (confirm guards, validation toggles,
sandbox defaults) must stay intact.
## Integration Approach
1. **Introduce ACP Helpers Without Regressions**
- Port `codex-rs/core/src/acp.rs:1` (and dependent structs) into Code, but
adapt it to reuse the current `FileChange` representations and respect
confirm guard / approval flows.
- Extend `codex-rs/core/src/codex.rs:1` to emit ACP events while preserving
the existing `Op` variants, queueing logic, and validation toggles.
- Update `codex-rs/core/src/util.rs:1`, `codex-rs/core/src/apply_patch.rs:1`,
and related modules so ACP tool-call generators can derive the same
metadata our TUI already consumes.
2. **MCP Server Wiring**
- Add `codex-rs/mcp-server/src/acp_tool_runner.rs:1` and integrate it with
`message_processor.rs:1`, ensuring we keep Code-specific auth/sandbox setup
and error reporting.
- Maintain existing MCP tools and ensure ACP tools are opt-in (guarded by
config or feature flag) so Code retains current behavior when ACP is
unused.
3. **Event & Frontend Compatibility**
- Extend our event enums (`codex-rs/core/src/protocol.rs:1`) with any new
variants required by ACP while keeping deprecated ones for backward
compatibility.
- Teach the Rust TUI to ignore or minimally display ACP-specific events so
terminal UX does not panic when ACP notifications flow through.
4. **Config & Build**
- Add `agent-client-protocol` dependency where needed, updating
`codex-rs/core/Cargo.toml:1`, `codex-rs/mcp-server/Cargo.toml:1`, and
`Cargo.lock`.
- Introduce configuration toggles (if any) in `codex-rs/core/src/config.rs:1`
and `codex-rs/core/src/config_types.rs:1` without breaking existing TOML
files.
- Update documentation (targeted sections in `docs/experimental.md:1` or a
new page) to describe ACP/Zed support plus Code-specific caveats.
5. **Testing & Validation**
- Add focused tests covering ACP request flow (unit-level in core and MCP
server). Reuse existing harnesses (e.g., `codex-rs/mcp-server/tests`) to
simulate a session.
- Validate end-to-end via `./build-fast.sh` only, honoring our policy against
running additional formatters automatically.
## Open Questions
- How should ACP be surfaced in Code’s configuration (auto-enabled vs per
server flag)?
- Do we expose ACP status in the Rust TUI, or treat it as headless-only? (Lean
toward headless-first with optional UI indicators.)
- Are there additional permission flows (e.g., review approvals) required for
Zed beyond what `ReviewDecision` already provides?
Document last updated: 2025-09-22.