From 8a6c6cee880436a2055a7afb2ebabd15d4921e4c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 20 Jul 2025 17:42:11 -0400 Subject: [PATCH 1/2] fix: address review feedback on #1621 and #1623 (#1631) - formalizes `ExecApprovalElicitRequestParams` - adds some defensive logic when messages fail to parse - fixes a typo in a comment --- codex-rs/mcp-server/src/codex_tool_runner.rs | 79 +++++++++++++++----- codex-rs/mcp-server/src/message_processor.rs | 2 +- 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index ae54599f9d..69a41bd67c 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -2,6 +2,7 @@ //! Tokio task. Separated from `message_processor.rs` to keep that file small //! and to make future feature-growth easier to manage. +use std::path::PathBuf; use std::sync::Arc; use codex_core::Codex; @@ -19,15 +20,19 @@ use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::ElicitRequest; use mcp_types::ElicitRequestParamsRequestedSchema; +use mcp_types::JSONRPCErrorError; use mcp_types::ModelContextProtocolRequest; use mcp_types::RequestId; use mcp_types::TextContent; use serde::Deserialize; +use serde::Serialize; use serde_json::json; use tracing::error; use crate::outgoing_message::OutgoingMessageSender; +const INVALID_PARAMS_ERROR_CODE: i64 = -32602; + /// Run a complete Codex session and stream events back to the client. /// /// On completion (success or error) the function sends the appropriate @@ -97,28 +102,44 @@ pub async fn run_codex_tool_session( .unwrap_or_else(|_| command.join(" ")); let message = format!("Allow Codex to run `{escaped_command}` in {cwd:?}?"); - let params = json!({ - // These fields are required so that `params` - // conforms to ElicitRequestParams. - "message": message, - "requestedSchema": ElicitRequestParamsRequestedSchema { + let params = ExecApprovalElicitRequestParams { + message, + requested_schema: ElicitRequestParamsRequestedSchema { r#type: "object".to_string(), properties: json!({}), required: None, }, + codex_elicitation: "exec-approval".to_string(), + codex_mcp_tool_call_id: sub_id.clone(), + codex_event_id: event.id.clone(), + codex_command: command, + codex_cwd: cwd, + }; + let params_json = match serde_json::to_value(¶ms) { + Ok(value) => value, + Err(err) => { + let message = format!( + "Failed to serialize ExecApprovalElicitRequestParams: {err}" + ); + tracing::error!("{message}"); + + outgoing + .send_error( + id.clone(), + JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message, + data: None, + }, + ) + .await; + + continue; + } + }; - // These are additional fields the client can use to - // correlate the request with the codex tool call. - "codex_elicitation": "exec-approval", - "codex_mcp_tool_call_id": sub_id, - "codex_event_id": event.id, - "codex_command": command, - // Could convert it to base64 encoded bytes if we - // don't want to use to_string_lossy() here? - "codex_cwd": cwd.to_string_lossy().to_string() - }); let on_response = outgoing - .send_request(ElicitRequest::METHOD, Some(params)) + .send_request(ElicitRequest::METHOD, Some(params_json)) .await; // Listen for the response on a separate task so we do @@ -236,7 +257,11 @@ async fn on_exec_approval_response( Ok(response) => response, Err(err) => { error!("failed to deserialize ExecApprovalResponse: {err}"); - return; + // If we cannot deserialize the response, we deny the request to be + // conservative. + ExecApprovalResponse { + decision: ReviewDecision::Denied, + } } }; @@ -255,3 +280,23 @@ async fn on_exec_approval_response( pub struct ExecApprovalResponse { pub decision: ReviewDecision, } + +/// Conforms to [`mcp_types::ElicitRequestParams`] so that it can be used as the +/// `params` field of an [`mcp_types::ElicitRequest`]. +#[derive(Debug, Serialize)] +struct ExecApprovalElicitRequestParams { + // These fields are required so that `params` + // conforms to ElicitRequestParams. + message: String, + + #[serde(rename = "requestedSchema")] + requested_schema: ElicitRequestParamsRequestedSchema, + + // These are additional fields the client can use to + // correlate the request with the codex tool call. + codex_elicitation: String, + codex_mcp_tool_call_id: String, + codex_event_id: String, + codex_command: Vec, + codex_cwd: PathBuf, +} diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index d994d8a7a2..a736c8c932 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -365,7 +365,7 @@ impl MessageProcessor { // Spawn an async task to handle the Codex session so that we do not // block the synchronous message-processing loop. task::spawn(async move { - // Run the Codex session and stream events Fck to the client. + // Run the Codex session and stream events back to the client. crate::codex_tool_runner::run_codex_tool_session(id, initial_prompt, config, outgoing) .await; }); From 96092108b15c0005b04fa6c3b01462dd1178d312 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 20 Jul 2025 14:42:16 -0700 Subject: [PATCH 2/2] fix: integration test for MCP server --- codex-rs/Cargo.lock | 4 + codex-rs/mcp-server/Cargo.toml | 4 + codex-rs/mcp-server/src/codex_tool_config.rs | 13 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 18 +- codex-rs/mcp-server/src/lib.rs | 4 + codex-rs/mcp-server/src/message_processor.rs | 2 +- codex-rs/mcp-server/tests/common/mod.rs | 315 +++++++++++++++++++ codex-rs/mcp-server/tests/elicitation.rs | 141 +++++++++ 8 files changed, 485 insertions(+), 16 deletions(-) create mode 100644 codex-rs/mcp-server/tests/common/mod.rs create mode 100644 codex-rs/mcp-server/tests/elicitation.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9171369ae2..6a8e76dd8a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -792,6 +792,7 @@ name = "codex-mcp-server" version = "0.0.0" dependencies = [ "anyhow", + "assert_cmd", "codex-core", "codex-linux-sandbox", "mcp-types", @@ -800,10 +801,13 @@ dependencies = [ "serde", "serde_json", "shlex", + "tempfile", "tokio", + "tokio-test", "toml 0.9.1", "tracing", "tracing-subscriber", + "wiremock", ] [[package]] diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 640a999317..f43b101bd9 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -35,4 +35,8 @@ tokio = { version = "1", features = [ ] } [dev-dependencies] +assert_cmd = "2" pretty_assertions = "1.4.1" +tempfile = "3" +tokio-test = "0.4" +wiremock = "0.6" diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index f54d29dd88..9a31dbcccc 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -7,15 +7,16 @@ use mcp_types::ToolInputSchema; use schemars::JsonSchema; use schemars::r#gen::SchemaSettings; use serde::Deserialize; +use serde::Serialize; use std::collections::HashMap; use std::path::PathBuf; use crate::json_to_toml::json_to_toml; /// Client-supplied configuration for a `codex` tool-call. -#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] -pub(crate) struct CodexToolCallParam { +pub struct CodexToolCallParam { /// The *initial user prompt* to start the Codex conversation. pub prompt: String, @@ -49,9 +50,9 @@ pub(crate) struct CodexToolCallParam { /// Custom enum mirroring [`AskForApproval`], but has an extra dependency on /// [`JsonSchema`]. -#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] -pub(crate) enum CodexToolCallApprovalPolicy { +pub enum CodexToolCallApprovalPolicy { Untrusted, OnFailure, Never, @@ -69,9 +70,9 @@ impl From for AskForApproval { /// Custom enum mirroring [`SandboxMode`] from config_types.rs, but with /// `JsonSchema` support. -#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] -pub(crate) enum CodexToolCallSandboxMode { +pub enum CodexToolCallSandboxMode { ReadOnly, WorkspaceWrite, DangerFullAccess, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 69a41bd67c..6575ff0cf1 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -276,7 +276,7 @@ async fn on_exec_approval_response( } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct ExecApprovalResponse { pub decision: ReviewDecision, } @@ -284,19 +284,19 @@ pub struct ExecApprovalResponse { /// Conforms to [`mcp_types::ElicitRequestParams`] so that it can be used as the /// `params` field of an [`mcp_types::ElicitRequest`]. #[derive(Debug, Serialize)] -struct ExecApprovalElicitRequestParams { +pub struct ExecApprovalElicitRequestParams { // These fields are required so that `params` // conforms to ElicitRequestParams. - message: String, + pub message: String, #[serde(rename = "requestedSchema")] - requested_schema: ElicitRequestParamsRequestedSchema, + pub requested_schema: ElicitRequestParamsRequestedSchema, // These are additional fields the client can use to // correlate the request with the codex tool call. - codex_elicitation: String, - codex_mcp_tool_call_id: String, - codex_event_id: String, - codex_command: Vec, - codex_cwd: PathBuf, + pub codex_elicitation: String, + pub codex_mcp_tool_call_id: String, + pub codex_event_id: String, + pub codex_command: Vec, + pub codex_cwd: PathBuf, } diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 3b984ecf13..1f1ecc3f2a 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -24,6 +24,10 @@ use crate::message_processor::MessageProcessor; use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::OutgoingMessageSender; +pub use crate::codex_tool_config::CodexToolCallParam; +pub use crate::codex_tool_runner::ExecApprovalElicitRequestParams; +pub use crate::codex_tool_runner::ExecApprovalResponse; + /// Size of the bounded channels used to communicate between tasks. The value /// is a balance between throughput and memory usage – 128 messages should be /// plenty for an interactive CLI. diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index a736c8c932..61c320edb9 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -185,7 +185,7 @@ impl MessageProcessor { protocol_version: params.protocol_version.clone(), server_info: mcp_types::Implementation { name: "codex-mcp-server".to_string(), - version: mcp_types::MCP_SCHEMA_VERSION.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), title: Some("Codex".to_string()), }, }; diff --git a/codex-rs/mcp-server/tests/common/mod.rs b/codex-rs/mcp-server/tests/common/mod.rs new file mode 100644 index 0000000000..302aa32694 --- /dev/null +++ b/codex-rs/mcp-server/tests/common/mod.rs @@ -0,0 +1,315 @@ +use std::io::BufRead; +use std::io::BufReader; +use std::path::Path; +use std::process::Child; +use std::process::ChildStdin; +use std::process::ChildStdout; +use std::process::Stdio; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use anyhow::Context; +use assert_cmd::prelude::*; +use codex_mcp_server::CodexToolCallParam; +use mcp_types::CallToolRequestParams; +use mcp_types::ClientCapabilities; +use mcp_types::Implementation; +use mcp_types::InitializeRequestParams; +use mcp_types::JSONRPC_VERSION; +use mcp_types::JSONRPCMessage; +use mcp_types::JSONRPCNotification; +use mcp_types::JSONRPCRequest; +use mcp_types::JSONRPCResponse; +use mcp_types::ModelContextProtocolNotification; +use mcp_types::ModelContextProtocolRequest; +use mcp_types::RequestId; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::io::Write; +use std::process::Command; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Respond; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +pub fn create_shell_sse_response( + command: Vec, + workdir: Option<&Path>, + timeout_ms: Option, +) -> anyhow::Result { + // The `arguments`` for the `shell` tool is a serialized JSON object. + let tool_call_arguments = serde_json::to_string(&json!({ + "command": command, + "workdir": workdir.map(|w| w.to_string_lossy()), + "timeout": timeout_ms + }))?; + let tool_call = json!({ + "choices": [ + { + "delta": { + "tool_calls": [ + { + "id": "call1234", + "function": { + "name": "shell", + "arguments": tool_call_arguments + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] + }); + + let sse = format!( + "data: {}\n\ndata: DONE\n\n", + serde_json::to_string(&tool_call)? + ); + Ok(sse) +} + +pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Result { + let assistant_message = json!({ + "choices": [ + { + "delta": { + "content": message + }, + "finish_reason": "stop" + } + ] + }); + + let sse = format!( + "data: {}\n\ndata: DONE\n\n", + serde_json::to_string(&assistant_message)? + ); + Ok(sse) +} + +pub struct McpProcess { + next_request_id: AtomicI64, + process: Child, + stdin: ChildStdin, + stdout: BufReader, +} + +impl McpProcess { + pub fn new(codex_home: &Path) -> anyhow::Result { + let mut cmd = Command::cargo_bin("codex-mcp-server") + .context("should find binary for codex-mcp-server")?; + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.env("CODEX_HOME", codex_home); + cmd.env("RUST_LOG", "debug"); + + let mut process = cmd.spawn().context("codex-mcp-server proc should start")?; + let stdin = process + .stdin + .take() + .ok_or_else(|| anyhow::format_err!("mcp should have stdin fd"))?; + let stdout = process + .stdout + .take() + .ok_or_else(|| anyhow::format_err!("mcp should have stdout fd"))?; + let stdout = BufReader::new(stdout); + Ok(Self { + next_request_id: AtomicI64::new(0), + process, + stdin, + stdout, + }) + } + + /// Performs the initialization handshake with the MCP server. + pub fn initialize(&mut self) -> anyhow::Result<()> { + let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); + + let params = InitializeRequestParams { + capabilities: ClientCapabilities { + elicitation: Some(json!({})), + experimental: None, + roots: None, + sampling: None, + }, + client_info: Implementation { + name: "elicitation test".into(), + title: Some("Elicitation Test".into()), + version: "0.0.0".into(), + }, + protocol_version: mcp_types::MCP_SCHEMA_VERSION.into(), + }; + let params_value = serde_json::to_value(params)?; + + self.send_jsonrpc_message(JSONRPCMessage::Request(JSONRPCRequest { + jsonrpc: JSONRPC_VERSION.into(), + id: RequestId::Integer(request_id), + method: mcp_types::InitializeRequest::METHOD.into(), + params: Some(params_value), + }))?; + + let initialized = self.read_jsonrpc_message()?; + assert_eq!( + JSONRPCMessage::Response(JSONRPCResponse { + jsonrpc: JSONRPC_VERSION.into(), + id: RequestId::Integer(request_id), + result: json!({ + "capabilities": { + "tools": { + "listChanged": true + }, + }, + "serverInfo": { + "name": "codex-mcp-server", + "title": "Codex", + "version": "0.0.0" + }, + "protocolVersion": mcp_types::MCP_SCHEMA_VERSION + }) + }), + initialized + ); + + // Send notifications/initialized to ack the response. + self.send_jsonrpc_message(JSONRPCMessage::Notification(JSONRPCNotification { + jsonrpc: JSONRPC_VERSION.into(), + method: mcp_types::InitializedNotification::METHOD.into(), + params: None, + }))?; + + Ok(()) + } + + /// Returns the id used to make the request so it can be used when + /// correlating notifications. + pub fn send_codex_tool_call(&mut self, prompt: &str) -> anyhow::Result { + let codex_tool_call_params = CallToolRequestParams { + name: "codex".to_string(), + arguments: Some(serde_json::to_value(CodexToolCallParam { + prompt: prompt.to_string(), + model: None, + profile: None, + cwd: None, + approval_policy: None, + sandbox: None, + config: None, + })?), + }; + self.send_request( + mcp_types::CallToolRequest::METHOD, + Some(serde_json::to_value(codex_tool_call_params)?), + ) + } + + fn send_request( + &mut self, + method: &str, + params: Option, + ) -> anyhow::Result { + let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); + + let message = JSONRPCMessage::Request(JSONRPCRequest { + jsonrpc: JSONRPC_VERSION.into(), + id: RequestId::Integer(request_id), + method: method.to_string(), + params, + }); + self.send_jsonrpc_message(message)?; + Ok(request_id) + } + + pub fn send_response( + &mut self, + id: RequestId, + result: serde_json::Value, + ) -> anyhow::Result<()> { + self.send_jsonrpc_message(JSONRPCMessage::Response(JSONRPCResponse { + jsonrpc: JSONRPC_VERSION.into(), + id, + result, + })) + } + + fn send_jsonrpc_message(&mut self, message: JSONRPCMessage) -> anyhow::Result<()> { + let payload = serde_json::to_string(&message)?; + self.stdin.write_all(payload.as_bytes())?; + self.stdin.write_all(b"\n")?; + self.stdin.flush()?; + Ok(()) + } + + fn read_jsonrpc_message(&mut self) -> anyhow::Result { + let mut line = String::new(); + self.stdout.read_line(&mut line)?; + let message = serde_json::from_str::(&line)?; + Ok(message) + } + + pub fn read_stream_until_request_message(&mut self) -> anyhow::Result { + loop { + let message = self.read_jsonrpc_message()?; + eprint!("message: {message:?}"); + + match message { + JSONRPCMessage::Notification(_) => { + eprintln!("notification: {message:?}"); + } + JSONRPCMessage::Request(jsonrpc_request) => { + return Ok(jsonrpc_request); + } + JSONRPCMessage::Error(_) => { + anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}"); + } + JSONRPCMessage::Response(_) => { + anyhow::bail!("unexpected JSONRPCMessage::Response: {message:?}"); + } + } + } + } +} + +impl Drop for McpProcess { + fn drop(&mut self) { + let _ = self.process.kill(); + } +} + +pub async fn create_mock_server(responses: Vec) -> MockServer { + let server = MockServer::start().await; + + let num_calls = responses.len(); + let seq_responder = SeqResponder { + num_calls: AtomicUsize::new(0), + responses, + }; + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(seq_responder) + .expect(num_calls as u64) + .mount(&server) + .await; + + server +} + +struct SeqResponder { + num_calls: AtomicUsize, + responses: Vec, +} + +impl Respond for SeqResponder { + fn respond(&self, _: &wiremock::Request) -> ResponseTemplate { + let call_num = self.num_calls.fetch_add(1, Ordering::SeqCst); + match self.responses.get(call_num) { + Some(response) => ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(response.clone(), "text/event-stream"), + None => panic!("no response for {call_num}"), + } + } +} diff --git a/codex-rs/mcp-server/tests/elicitation.rs b/codex-rs/mcp-server/tests/elicitation.rs new file mode 100644 index 0000000000..c4ef8d4b45 --- /dev/null +++ b/codex-rs/mcp-server/tests/elicitation.rs @@ -0,0 +1,141 @@ +mod common; + +use std::path::Path; +use std::thread; +use std::time::Duration; + +use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_core::protocol::ReviewDecision; +use codex_mcp_server::ExecApprovalElicitRequestParams; +use codex_mcp_server::ExecApprovalResponse; +use mcp_types::ElicitRequest; +use mcp_types::ElicitRequestParamsRequestedSchema; +use mcp_types::JSONRPC_VERSION; +use mcp_types::JSONRPCRequest; +use mcp_types::ModelContextProtocolRequest; +use mcp_types::RequestId; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; + +use crate::common::McpProcess; +use crate::common::create_final_assistant_message_sse_response; +use crate::common::create_mock_server; +use crate::common::create_shell_sse_response; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_shell_command_approval_triggers_elicitation() { + if std::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; + } + + // Apparently `#[tokio::test]` must return `()`, so we create a helper + // function that returns `Result` so we can use `?` in favor of `unwrap`. + if let Err(err) = shell_command_approval_triggers_elicitation().await { + panic!("failure: {err}"); + } +} + +async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { + let workdir_for_shell_function_call = TempDir::new()?; + + let chat_completions_responses = vec![ + create_shell_sse_response( + // We use `git init` because it will not be on the "trusted" list. + vec!["git".to_string(), "init".to_string()], + Some(workdir_for_shell_function_call.path()), + Some(5_000), + )?, + create_final_assistant_message_sse_response("Enjoy your new git repo!")?, + ]; + let server = create_mock_server(chat_completions_responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), server.uri())?; + + // TODO(mbolin): Introduce timeouts for individual MCP interactions. + let mut mcp_process = McpProcess::new(codex_home.path())?; + mcp_process.initialize()?; + + // Send "codex" tool request, which should hit the completions endpoint, + // which should reply with a tool call, which the MCP should forward as an + // elicitation. + let request_id = mcp_process.send_codex_tool_call("run `git init`")?; + let jsonrpc_request = mcp_process.read_stream_until_request_message()?; + + // This is the first request from the server, so the id should be 0 given + // how things are currently implemented. + let elicitation_request_id = RequestId::Integer(0); + assert_eq!( + JSONRPCRequest { + jsonrpc: JSONRPC_VERSION.into(), + id: elicitation_request_id.clone(), + method: ElicitRequest::METHOD.to_string(), + params: Some(serde_json::to_value(&ExecApprovalElicitRequestParams { + message: format!( + "Allow Codex to run `git init` in \"{workdir}\"?", + workdir = workdir_for_shell_function_call.path().to_string_lossy() + ), + requested_schema: ElicitRequestParamsRequestedSchema { + r#type: "object".to_string(), + properties: json!({}), + required: None, + }, + codex_elicitation: "exec-approval".to_string(), + codex_mcp_tool_call_id: request_id.to_string(), + // Internal Codex id: empirically it is 1, but this is + // admittedly an internal detail that could change. + codex_event_id: "1".to_string(), + codex_command: vec!["git".into(), "init".into()], + codex_cwd: workdir_for_shell_function_call.path().to_path_buf() + })?) + }, + jsonrpc_request + ); + + // Accept the `git init` request. + mcp_process.send_response( + elicitation_request_id, + serde_json::to_value(ExecApprovalResponse { + decision: ReviewDecision::Approved, + })?, + )?; + + thread::sleep(Duration::from_secs(5)); + + assert!( + workdir_for_shell_function_call.path().join(".git").is_dir(), + ".git folder should have been created" + ); + + // TODO(mbolin): Verify the other responses that should have come back. + + Ok(()) +} + +fn create_config_toml(codex_home: &Path, server_uri: String) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" + +model = "gpt-1000" +approval_policy = "untrusted" +sandbox_policy = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +}