diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c8f362aa03..6707e75bbb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -165,6 +165,20 @@ version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +[[package]] +name = "app_test_support" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "codex-protocol", + "mcp-types", + "serde", + "serde_json", + "tokio", + "wiremock", +] + [[package]] name = "arboard" version = "3.6.0" @@ -636,6 +650,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "codex-app-server" +version = "0.0.0" +dependencies = [ + "anyhow", + "app_test_support", + "assert_cmd", + "base64", + "codex-arg0", + "codex-common", + "codex-core", + "codex-file-search", + "codex-login", + "codex-protocol", + "codex-utils-json-to-toml", + "core_test_support", + "mcp-types", + "os_info", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "uuid", + "wiremock", +] + [[package]] name = "codex-apply-patch" version = "0.0.0" @@ -685,6 +729,7 @@ dependencies = [ "assert_cmd", "clap", "clap_complete", + "codex-app-server", "codex-arg0", "codex-chatgpt", "codex-common", @@ -914,13 +959,11 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", - "base64", "codex-arg0", "codex-common", "codex-core", - "codex-file-search", - "codex-login", "codex-protocol", + "codex-utils-json-to-toml", "core_test_support", "mcp-types", "mcp_test_support", @@ -932,10 +975,8 @@ dependencies = [ "shlex", "tempfile", "tokio", - "toml", "tracing", "tracing-subscriber", - "uuid", "wiremock", ] @@ -1104,6 +1145,15 @@ dependencies = [ "vt100", ] +[[package]] +name = "codex-utils-json-to-toml" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "serde_json", + "toml", +] + [[package]] name = "codex-utils-readiness" version = "0.0.0" @@ -3004,7 +3054,6 @@ dependencies = [ "assert_cmd", "codex-core", "codex-mcp-server", - "codex-protocol", "mcp-types", "os_info", "pretty_assertions", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1c591897ee..7946f1d0e7 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "ansi-escape", + "app-server", "apply-patch", "arg0", "cli", @@ -23,6 +24,7 @@ members = [ "responses-api-proxy", "otel", "tui", + "utils/json-to-toml", "utils/readiness", ] resolver = "2" @@ -37,7 +39,9 @@ edition = "2024" [workspace.dependencies] # Internal +app_test_support = { path = "app-server/tests/common" } codex-ansi-escape = { path = "ansi-escape" } +codex-app-server = { path = "app-server" } codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-chatgpt = { path = "chatgpt" } @@ -51,12 +55,13 @@ codex-login = { path = "login" } codex-mcp-client = { path = "mcp-client" } codex-mcp-server = { path = "mcp-server" } codex-ollama = { path = "ollama" } +codex-otel = { path = "otel" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } codex-protocol-ts = { path = "protocol-ts" } codex-rmcp-client = { path = "rmcp-client" } codex-tui = { path = "tui" } -codex-otel = { path = "otel" } +codex-utils-json-to-toml = { path = "utils/json-to-toml" } codex-utils-readiness = { path = "utils/readiness" } core_test_support = { path = "core/tests/common" } mcp-types = { path = "mcp-types" } @@ -106,10 +111,10 @@ multimap = "0.10.0" nucleo-matcher = "0.3.1" openssl-sys = "*" opentelemetry = "0.30.0" -opentelemetry_sdk = "0.30.0" -opentelemetry-otlp = "0.30.0" opentelemetry-appender-tracing = "0.30.0" +opentelemetry-otlp = "0.30.0" opentelemetry-semantic-conventions = "0.30.0" +opentelemetry_sdk = "0.30.0" os_info = "3.12.0" owo-colors = "4.2.0" path-absolutize = "3.1.1" @@ -148,6 +153,7 @@ tokio-test = "0.4" tokio-util = "0.7.16" toml = "0.9.5" toml_edit = "0.23.4" +tonic = "0.13.1" tracing = "0.1.41" tracing-appender = "0.2.3" tracing-subscriber = "0.3.20" @@ -155,7 +161,6 @@ tracing-test = "0.2.5" tree-sitter = "0.25.9" tree-sitter-bash = "0.25.0" ts-rs = "11" -tonic = "0.13.1" unicode-segmentation = "1.12.0" unicode-width = "0.2" url = "2" diff --git a/codex-rs/README.md b/codex-rs/README.md index f51f746892..46eda63a1e 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -25,12 +25,14 @@ Codex supports a rich set of configuration options. Note that the Rust CLI uses Codex CLI functions as an MCP client that can connect to MCP servers on startup. See the [`mcp_servers`](../docs/config.md#mcp_servers) section in the configuration documentation for details. -It is still experimental, but you can also launch Codex as an MCP _server_ by running `codex mcp`. Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out: +It is still experimental, but you can also launch Codex as an MCP _server_ by running `codex mcp-server`. Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out: ```shell -npx @modelcontextprotocol/inspector codex mcp +npx @modelcontextprotocol/inspector codex mcp-server ``` +Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.toml`, and `codex mcp-server` to run the MCP server directly. + ### Notifications You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml new file mode 100644 index 0000000000..1cc25ef2d1 --- /dev/null +++ b/codex-rs/app-server/Cargo.toml @@ -0,0 +1,51 @@ +[package] +edition = "2024" +name = "codex-app-server" +version = { workspace = true } + +[[bin]] +name = "codex-app-server" +path = "src/main.rs" + +[lib] +name = "codex_app_server" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-arg0 = { workspace = true } +codex-common = { workspace = true, features = ["cli"] } +codex-core = { workspace = true } +codex-file-search = { workspace = true } +codex-login = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-json-to-toml = { workspace = true } +# We should only be using mcp-types for JSON-RPC types: it would be nice to +# split this out into a separate crate at some point. +mcp-types = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = [ + "io-std", + "macros", + "process", + "rt-multi-thread", + "signal", +] } +tracing = { workspace = true, features = ["log"] } +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } +uuid = { workspace = true, features = ["serde", "v7"] } + +[dev-dependencies] +app_test_support = { workspace = true } +assert_cmd = { workspace = true } +base64 = { workspace = true } +core_test_support = { workspace = true } +os_info = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +toml = { workspace = true } +wiremock = { workspace = true } diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs similarity index 98% rename from codex-rs/mcp-server/src/codex_message_processor.rs rename to codex-rs/app-server/src/codex_message_processor.rs index 53830431fd..4d11aad421 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,7 +1,6 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::fuzzy_file_search::run_fuzzy_file_search; -use crate::json_to_toml::json_to_toml; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; use codex_core::AuthManager; @@ -77,6 +76,7 @@ use codex_protocol::mcp_protocol::SendUserMessageResponse; use codex_protocol::mcp_protocol::SendUserTurnParams; use codex_protocol::mcp_protocol::SendUserTurnResponse; use codex_protocol::mcp_protocol::ServerNotification; +use codex_protocol::mcp_protocol::SessionConfiguredNotification; use codex_protocol::mcp_protocol::SetDefaultModelParams; use codex_protocol::mcp_protocol::SetDefaultModelResponse; use codex_protocol::mcp_protocol::UserInfoResponse; @@ -85,6 +85,7 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InputMessageKind; use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use codex_utils_json_to_toml::json_to_toml; use mcp_types::JSONRPCErrorError; use mcp_types::RequestId; use std::collections::HashMap; @@ -153,6 +154,9 @@ impl CodexMessageProcessor { pub async fn process_request(&mut self, request: ClientRequest) { match request { + ClientRequest::Initialize { .. } => { + panic!("Initialize should be handled in MessageProcessor"); + } ClientRequest::NewConversation { request_id, params } => { // Do not tokio::spawn() to process new_conversation() // asynchronously because we need to ensure the conversation is @@ -762,11 +766,19 @@ impl CodexMessageProcessor { session_configured, .. }) => { - let event = Event { - id: "".to_string(), - msg: EventMsg::SessionConfigured(session_configured.clone()), - }; - self.outgoing.send_event_as_notification(&event, None).await; + self.outgoing + .send_server_notification(ServerNotification::SessionConfigured( + SessionConfiguredNotification { + session_id: session_configured.session_id, + model: session_configured.model.clone(), + reasoning_effort: session_configured.reasoning_effort, + history_log_id: session_configured.history_log_id, + history_entry_count: session_configured.history_entry_count, + initial_messages: session_configured.initial_messages.clone(), + rollout_path: session_configured.rollout_path.clone(), + }, + )) + .await; let initial_messages = session_configured.initial_messages.map(|msgs| { msgs.into_iter() .filter(|event| { diff --git a/codex-rs/app-server/src/error_code.rs b/codex-rs/app-server/src/error_code.rs new file mode 100644 index 0000000000..1ffd889d40 --- /dev/null +++ b/codex-rs/app-server/src/error_code.rs @@ -0,0 +1,2 @@ +pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603; diff --git a/codex-rs/mcp-server/src/fuzzy_file_search.rs b/codex-rs/app-server/src/fuzzy_file_search.rs similarity index 100% rename from codex-rs/mcp-server/src/fuzzy_file_search.rs rename to codex-rs/app-server/src/fuzzy_file_search.rs diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs new file mode 100644 index 0000000000..a0c40db64a --- /dev/null +++ b/codex-rs/app-server/src/lib.rs @@ -0,0 +1,139 @@ +#![deny(clippy::print_stdout, clippy::print_stderr)] + +use std::io::ErrorKind; +use std::io::Result as IoResult; +use std::path::PathBuf; + +use codex_common::CliConfigOverrides; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; + +use mcp_types::JSONRPCMessage; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::io::{self}; +use tokio::sync::mpsc; +use tracing::debug; +use tracing::error; +use tracing::info; +use tracing_subscriber::EnvFilter; + +use crate::message_processor::MessageProcessor; +use crate::outgoing_message::OutgoingMessage; +use crate::outgoing_message::OutgoingMessageSender; + +mod codex_message_processor; +mod error_code; +mod fuzzy_file_search; +mod message_processor; +mod outgoing_message; + +/// 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. +const CHANNEL_CAPACITY: usize = 128; + +pub async fn run_main( + codex_linux_sandbox_exe: Option, + cli_config_overrides: CliConfigOverrides, +) -> IoResult<()> { + // Install a simple subscriber so `tracing` output is visible. Users can + // control the log level with `RUST_LOG`. + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + // Set up channels. + let (incoming_tx, mut incoming_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); + + // Task: read from stdin, push to `incoming_tx`. + let stdin_reader_handle = tokio::spawn({ + async move { + let stdin = io::stdin(); + let reader = BufReader::new(stdin); + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await.unwrap_or_default() { + match serde_json::from_str::(&line) { + Ok(msg) => { + if incoming_tx.send(msg).await.is_err() { + // Receiver gone – nothing left to do. + break; + } + } + Err(e) => error!("Failed to deserialize JSONRPCMessage: {e}"), + } + } + + debug!("stdin reader finished (EOF)"); + } + }); + + // Parse CLI overrides once and derive the base Config eagerly so later + // components do not need to work with raw TOML values. + let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| { + std::io::Error::new( + ErrorKind::InvalidInput, + format!("error parsing -c overrides: {e}"), + ) + })?; + let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default()) + .map_err(|e| { + std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) + })?; + + // Task: process incoming messages. + let processor_handle = tokio::spawn({ + let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); + let mut processor = MessageProcessor::new( + outgoing_message_sender, + codex_linux_sandbox_exe, + std::sync::Arc::new(config), + ); + async move { + while let Some(msg) = incoming_rx.recv().await { + match msg { + JSONRPCMessage::Request(r) => processor.process_request(r).await, + JSONRPCMessage::Response(r) => processor.process_response(r).await, + JSONRPCMessage::Notification(n) => processor.process_notification(n).await, + JSONRPCMessage::Error(e) => processor.process_error(e), + } + } + + info!("processor task exited (channel closed)"); + } + }); + + // Task: write outgoing messages to stdout. + let stdout_writer_handle = tokio::spawn(async move { + let mut stdout = io::stdout(); + while let Some(outgoing_message) = outgoing_rx.recv().await { + let msg: JSONRPCMessage = outgoing_message.into(); + match serde_json::to_string(&msg) { + Ok(json) => { + if let Err(e) = stdout.write_all(json.as_bytes()).await { + error!("Failed to write to stdout: {e}"); + break; + } + if let Err(e) = stdout.write_all(b"\n").await { + error!("Failed to write newline to stdout: {e}"); + break; + } + } + Err(e) => error!("Failed to serialize JSONRPCMessage: {e}"), + } + } + + info!("stdout writer exited (channel closed)"); + }); + + // Wait for all tasks to finish. The typical exit path is the stdin reader + // hitting EOF which, once it drops `incoming_tx`, propagates shutdown to + // the processor and then to the stdout task. + let _ = tokio::join!(stdin_reader_handle, processor_handle, stdout_writer_handle); + + Ok(()) +} diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs new file mode 100644 index 0000000000..689ec0877a --- /dev/null +++ b/codex-rs/app-server/src/main.rs @@ -0,0 +1,10 @@ +use codex_app_server::run_main; +use codex_arg0::arg0_dispatch_or_else; +use codex_common::CliConfigOverrides; + +fn main() -> anyhow::Result<()> { + arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + run_main(codex_linux_sandbox_exe, CliConfigOverrides::default()).await?; + Ok(()) + }) +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs new file mode 100644 index 0000000000..24e0f2320a --- /dev/null +++ b/codex-rs/app-server/src/message_processor.rs @@ -0,0 +1,133 @@ +use std::path::PathBuf; + +use crate::codex_message_processor::CodexMessageProcessor; +use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::outgoing_message::OutgoingMessageSender; +use codex_protocol::mcp_protocol::ClientInfo; +use codex_protocol::mcp_protocol::ClientRequest; +use codex_protocol::mcp_protocol::InitializeResponse; + +use codex_core::AuthManager; +use codex_core::ConversationManager; +use codex_core::config::Config; +use codex_core::default_client::USER_AGENT_SUFFIX; +use codex_core::default_client::get_codex_user_agent; +use mcp_types::JSONRPCError; +use mcp_types::JSONRPCErrorError; +use mcp_types::JSONRPCNotification; +use mcp_types::JSONRPCRequest; +use mcp_types::JSONRPCResponse; +use std::sync::Arc; + +pub(crate) struct MessageProcessor { + outgoing: Arc, + codex_message_processor: CodexMessageProcessor, + initialized: bool, +} + +impl MessageProcessor { + /// Create a new `MessageProcessor`, retaining a handle to the outgoing + /// `Sender` so handlers can enqueue messages to be written to stdout. + pub(crate) fn new( + outgoing: OutgoingMessageSender, + codex_linux_sandbox_exe: Option, + config: Arc, + ) -> Self { + let outgoing = Arc::new(outgoing); + let auth_manager = AuthManager::shared(config.codex_home.clone()); + let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); + let codex_message_processor = CodexMessageProcessor::new( + auth_manager, + conversation_manager, + outgoing.clone(), + codex_linux_sandbox_exe, + config, + ); + + Self { + outgoing, + codex_message_processor, + initialized: false, + } + } + + pub(crate) async fn process_request(&mut self, request: JSONRPCRequest) { + let request_id = request.id.clone(); + if let Ok(request_json) = serde_json::to_value(request) + && let Ok(codex_request) = serde_json::from_value::(request_json) + { + match codex_request { + // Handle Initialize internally so CodexMessageProcessor does not have to concern + // itself with the `initialized` bool. + ClientRequest::Initialize { request_id, params } => { + if self.initialized { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "Already initialized".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } else { + let ClientInfo { + name, + title: _title, + version, + } = params.client_info; + let user_agent_suffix = format!("{name}; {version}"); + if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { + *suffix = Some(user_agent_suffix); + } + + let user_agent = get_codex_user_agent(); + let response = InitializeResponse { user_agent }; + self.outgoing.send_response(request_id, response).await; + + self.initialized = true; + return; + } + } + _ => { + if !self.initialized { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "Not initialized".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + } + } + + self.codex_message_processor + .process_request(codex_request) + .await; + } else { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "Invalid request".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + + pub(crate) async fn process_notification(&self, notification: JSONRPCNotification) { + // Currently, we do not expect to receive any notifications from the + // client, so we just log them. + tracing::info!("<- notification: {:?}", notification); + } + + /// Handle a standalone JSON-RPC response originating from the peer. + pub(crate) async fn process_response(&mut self, response: JSONRPCResponse) { + tracing::info!("<- response: {:?}", response); + let JSONRPCResponse { id, result, .. } = response; + self.outgoing.notify_client_response(id, result).await + } + + /// Handle an error object received from the peer. + pub(crate) fn process_error(&mut self, err: JSONRPCError) { + tracing::error!("<- error: {:?}", err); + } +} diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs new file mode 100644 index 0000000000..25d5e7aa52 --- /dev/null +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::Ordering; + +use codex_protocol::mcp_protocol::ServerNotification; +use mcp_types::JSONRPC_VERSION; +use mcp_types::JSONRPCError; +use mcp_types::JSONRPCErrorError; +use mcp_types::JSONRPCMessage; +use mcp_types::JSONRPCNotification; +use mcp_types::JSONRPCRequest; +use mcp_types::JSONRPCResponse; +use mcp_types::RequestId; +use mcp_types::Result; +use serde::Serialize; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tracing::warn; + +use crate::error_code::INTERNAL_ERROR_CODE; + +/// Sends messages to the client and manages request callbacks. +pub(crate) struct OutgoingMessageSender { + next_request_id: AtomicI64, + sender: mpsc::UnboundedSender, + request_id_to_callback: Mutex>>, +} + +impl OutgoingMessageSender { + pub(crate) fn new(sender: mpsc::UnboundedSender) -> Self { + Self { + next_request_id: AtomicI64::new(0), + sender, + request_id_to_callback: Mutex::new(HashMap::new()), + } + } + + pub(crate) async fn send_request( + &self, + method: &str, + params: Option, + ) -> oneshot::Receiver { + let id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed)); + let outgoing_message_id = id.clone(); + let (tx_approve, rx_approve) = oneshot::channel(); + { + let mut request_id_to_callback = self.request_id_to_callback.lock().await; + request_id_to_callback.insert(id, tx_approve); + } + + let outgoing_message = OutgoingMessage::Request(OutgoingRequest { + id: outgoing_message_id, + method: method.to_string(), + params, + }); + let _ = self.sender.send(outgoing_message); + rx_approve + } + + pub(crate) async fn notify_client_response(&self, id: RequestId, result: Result) { + let entry = { + let mut request_id_to_callback = self.request_id_to_callback.lock().await; + request_id_to_callback.remove_entry(&id) + }; + + match entry { + Some((id, sender)) => { + if let Err(err) = sender.send(result) { + warn!("could not notify callback for {id:?} due to: {err:?}"); + } + } + None => { + warn!("could not find callback for {id:?}"); + } + } + } + + pub(crate) async fn send_response(&self, id: RequestId, response: T) { + match serde_json::to_value(response) { + Ok(result) => { + let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result }); + let _ = self.sender.send(outgoing_message); + } + Err(err) => { + self.send_error( + id, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to serialize response: {err}"), + data: None, + }, + ) + .await; + } + } + } + + pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { + let _ = self + .sender + .send(OutgoingMessage::AppServerNotification(notification)); + } + + /// All notifications should be migrated to [`ServerNotification`] and + /// [`OutgoingMessage::Notification`] should be removed. + pub(crate) async fn send_notification(&self, notification: OutgoingNotification) { + let outgoing_message = OutgoingMessage::Notification(notification); + let _ = self.sender.send(outgoing_message); + } + + pub(crate) async fn send_error(&self, id: RequestId, error: JSONRPCErrorError) { + let outgoing_message = OutgoingMessage::Error(OutgoingError { id, error }); + let _ = self.sender.send(outgoing_message); + } +} + +/// Outgoing message from the server to the client. +pub(crate) enum OutgoingMessage { + Request(OutgoingRequest), + Notification(OutgoingNotification), + /// AppServerNotification is specific to the case where this is run as an + /// "app server" as opposed to an MCP server. + AppServerNotification(ServerNotification), + Response(OutgoingResponse), + Error(OutgoingError), +} + +impl From for JSONRPCMessage { + fn from(val: OutgoingMessage) -> Self { + use OutgoingMessage::*; + match val { + Request(OutgoingRequest { id, method, params }) => { + JSONRPCMessage::Request(JSONRPCRequest { + jsonrpc: JSONRPC_VERSION.into(), + id, + method, + params, + }) + } + Notification(OutgoingNotification { method, params }) => { + JSONRPCMessage::Notification(JSONRPCNotification { + jsonrpc: JSONRPC_VERSION.into(), + method, + params, + }) + } + AppServerNotification(notification) => { + let method = notification.to_string(); + let params = match notification.to_params() { + Ok(params) => Some(params), + Err(err) => { + warn!("failed to serialize notification params: {err}"); + None + } + }; + JSONRPCMessage::Notification(JSONRPCNotification { + jsonrpc: JSONRPC_VERSION.into(), + method, + params, + }) + } + Response(OutgoingResponse { id, result }) => { + JSONRPCMessage::Response(JSONRPCResponse { + jsonrpc: JSONRPC_VERSION.into(), + id, + result, + }) + } + Error(OutgoingError { id, error }) => JSONRPCMessage::Error(JSONRPCError { + jsonrpc: JSONRPC_VERSION.into(), + id, + error, + }), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingRequest { + pub id: RequestId, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingNotification { + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingResponse { + pub id: RequestId, + pub result: Result, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingError { + pub error: JSONRPCErrorError, + pub id: RequestId, +} + +#[cfg(test)] +mod tests { + use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification; + use pretty_assertions::assert_eq; + use serde_json::json; + use uuid::Uuid; + + use super::*; + + #[test] + fn verify_server_notification_serialization() { + let notification = + ServerNotification::LoginChatGptComplete(LoginChatGptCompleteNotification { + login_id: Uuid::nil(), + success: true, + error: None, + }); + + let jsonrpc_notification: JSONRPCMessage = + OutgoingMessage::AppServerNotification(notification).into(); + assert_eq!( + JSONRPCMessage::Notification(JSONRPCNotification { + jsonrpc: "2.0".into(), + method: "loginChatGptComplete".into(), + params: Some(json!({ + "loginId": Uuid::nil(), + "success": true, + })), + }), + jsonrpc_notification, + "ensure the strum macros serialize the method field correctly" + ); + } +} diff --git a/codex-rs/app-server/tests/all.rs b/codex-rs/app-server/tests/all.rs new file mode 100644 index 0000000000..7e136e4cce --- /dev/null +++ b/codex-rs/app-server/tests/all.rs @@ -0,0 +1,3 @@ +// Single integration test binary that aggregates all test modules. +// The submodules live in `tests/suite/`. +mod suite; diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml new file mode 100644 index 0000000000..4cc711fb6b --- /dev/null +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -0,0 +1,22 @@ +[package] +edition = "2024" +name = "app_test_support" +version = { workspace = true } + +[lib] +path = "lib.rs" + +[dependencies] +anyhow = { workspace = true } +assert_cmd = { workspace = true } +codex-protocol = { workspace = true } +mcp-types = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = [ + "io-std", + "macros", + "process", + "rt-multi-thread", +] } +wiremock = { workspace = true } diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs new file mode 100644 index 0000000000..d088b184ea --- /dev/null +++ b/codex-rs/app-server/tests/common/lib.rs @@ -0,0 +1,17 @@ +mod mcp_process; +mod mock_model_server; +mod responses; + +pub use mcp_process::McpProcess; +use mcp_types::JSONRPCResponse; +pub use mock_model_server::create_mock_chat_completions_server; +pub use responses::create_apply_patch_sse_response; +pub use responses::create_final_assistant_message_sse_response; +pub use responses::create_shell_sse_response; +use serde::de::DeserializeOwned; + +pub fn to_response(response: JSONRPCResponse) -> anyhow::Result { + let value = serde_json::to_value(response.result)?; + let codex_response = serde_json::from_value(value)?; + Ok(codex_response) +} diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs new file mode 100644 index 0000000000..cbf54cecf0 --- /dev/null +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -0,0 +1,477 @@ +use std::path::Path; +use std::process::Stdio; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::Ordering; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::process::Child; +use tokio::process::ChildStdin; +use tokio::process::ChildStdout; + +use anyhow::Context; +use assert_cmd::prelude::*; +use codex_protocol::mcp_protocol::AddConversationListenerParams; +use codex_protocol::mcp_protocol::ArchiveConversationParams; +use codex_protocol::mcp_protocol::CancelLoginChatGptParams; +use codex_protocol::mcp_protocol::ClientInfo; +use codex_protocol::mcp_protocol::ClientNotification; +use codex_protocol::mcp_protocol::GetAuthStatusParams; +use codex_protocol::mcp_protocol::InitializeParams; +use codex_protocol::mcp_protocol::InterruptConversationParams; +use codex_protocol::mcp_protocol::ListConversationsParams; +use codex_protocol::mcp_protocol::LoginApiKeyParams; +use codex_protocol::mcp_protocol::NewConversationParams; +use codex_protocol::mcp_protocol::RemoveConversationListenerParams; +use codex_protocol::mcp_protocol::ResumeConversationParams; +use codex_protocol::mcp_protocol::SendUserMessageParams; +use codex_protocol::mcp_protocol::SendUserTurnParams; +use codex_protocol::mcp_protocol::SetDefaultModelParams; + +use mcp_types::JSONRPC_VERSION; +use mcp_types::JSONRPCMessage; +use mcp_types::JSONRPCNotification; +use mcp_types::JSONRPCRequest; +use mcp_types::JSONRPCResponse; +use mcp_types::RequestId; +use std::process::Command as StdCommand; +use tokio::process::Command; + +pub struct McpProcess { + next_request_id: AtomicI64, + /// Retain this child process until the client is dropped. The Tokio runtime + /// will make a "best effort" to reap the process after it exits, but it is + /// not a guarantee. See the `kill_on_drop` documentation for details. + #[allow(dead_code)] + process: Child, + stdin: ChildStdin, + stdout: BufReader, +} + +impl McpProcess { + pub async fn new(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[]).await + } + + /// Creates a new MCP process, allowing tests to override or remove + /// specific environment variables for the child process only. + /// + /// Pass a tuple of (key, Some(value)) to set/override, or (key, None) to + /// remove a variable from the child's environment. + pub async fn new_with_env( + codex_home: &Path, + env_overrides: &[(&str, Option<&str>)], + ) -> anyhow::Result { + // Use assert_cmd to locate the binary path and then switch to tokio::process::Command + let std_cmd = StdCommand::cargo_bin("codex-app-server") + .context("should find binary for codex-mcp-server")?; + + let program = std_cmd.get_program().to_owned(); + + let mut cmd = Command::new(program); + + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + cmd.env("CODEX_HOME", codex_home); + cmd.env("RUST_LOG", "debug"); + + for (k, v) in env_overrides { + match v { + Some(val) => { + cmd.env(k, val); + } + None => { + cmd.env_remove(k); + } + } + } + + let mut process = cmd + .kill_on_drop(true) + .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); + + // Forward child's stderr to our stderr so failures are visible even + // when stdout/stderr are captured by the test harness. + if let Some(stderr) = process.stderr.take() { + let mut stderr_reader = BufReader::new(stderr).lines(); + tokio::spawn(async move { + while let Ok(Some(line)) = stderr_reader.next_line().await { + eprintln!("[mcp stderr] {line}"); + } + }); + } + Ok(Self { + next_request_id: AtomicI64::new(0), + process, + stdin, + stdout, + }) + } + + /// Performs the initialization handshake with the MCP server. + pub async fn initialize(&mut self) -> anyhow::Result<()> { + let params = Some(serde_json::to_value(InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + })?); + let req_id = self.send_request("initialize", params).await?; + let initialized = self.read_jsonrpc_message().await?; + let JSONRPCMessage::Response(response) = initialized else { + unreachable!("expected JSONRPCMessage::Response for initialize, got {initialized:?}"); + }; + if response.id != RequestId::Integer(req_id) { + anyhow::bail!( + "initialize response id mismatch: expected {}, got {:?}", + req_id, + response.id + ); + } + + // Send notifications/initialized to ack the response. + self.send_notification(ClientNotification::Initialized) + .await?; + + Ok(()) + } + + /// Send a `newConversation` JSON-RPC request. + pub async fn send_new_conversation_request( + &mut self, + params: NewConversationParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("newConversation", params).await + } + + /// Send an `archiveConversation` JSON-RPC request. + pub async fn send_archive_conversation_request( + &mut self, + params: ArchiveConversationParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("archiveConversation", params).await + } + + /// Send an `addConversationListener` JSON-RPC request. + pub async fn send_add_conversation_listener_request( + &mut self, + params: AddConversationListenerParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("addConversationListener", params).await + } + + /// Send a `sendUserMessage` JSON-RPC request with a single text item. + pub async fn send_send_user_message_request( + &mut self, + params: SendUserMessageParams, + ) -> anyhow::Result { + // Wire format expects variants in camelCase; text item uses external tagging. + let params = Some(serde_json::to_value(params)?); + self.send_request("sendUserMessage", params).await + } + + /// Send a `removeConversationListener` JSON-RPC request. + pub async fn send_remove_conversation_listener_request( + &mut self, + params: RemoveConversationListenerParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("removeConversationListener", params) + .await + } + + /// Send a `sendUserTurn` JSON-RPC request. + pub async fn send_send_user_turn_request( + &mut self, + params: SendUserTurnParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("sendUserTurn", params).await + } + + /// Send a `interruptConversation` JSON-RPC request. + pub async fn send_interrupt_conversation_request( + &mut self, + params: InterruptConversationParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("interruptConversation", params).await + } + + /// Send a `getAuthStatus` JSON-RPC request. + pub async fn send_get_auth_status_request( + &mut self, + params: GetAuthStatusParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("getAuthStatus", params).await + } + + /// Send a `getUserSavedConfig` JSON-RPC request. + pub async fn send_get_user_saved_config_request(&mut self) -> anyhow::Result { + self.send_request("getUserSavedConfig", None).await + } + + /// Send a `getUserAgent` JSON-RPC request. + pub async fn send_get_user_agent_request(&mut self) -> anyhow::Result { + self.send_request("getUserAgent", None).await + } + + /// Send a `userInfo` JSON-RPC request. + pub async fn send_user_info_request(&mut self) -> anyhow::Result { + self.send_request("userInfo", None).await + } + + /// Send a `setDefaultModel` JSON-RPC request. + pub async fn send_set_default_model_request( + &mut self, + params: SetDefaultModelParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("setDefaultModel", params).await + } + + /// Send a `listConversations` JSON-RPC request. + pub async fn send_list_conversations_request( + &mut self, + params: ListConversationsParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("listConversations", params).await + } + + /// Send a `resumeConversation` JSON-RPC request. + pub async fn send_resume_conversation_request( + &mut self, + params: ResumeConversationParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("resumeConversation", params).await + } + + /// Send a `loginApiKey` JSON-RPC request. + pub async fn send_login_api_key_request( + &mut self, + params: LoginApiKeyParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("loginApiKey", params).await + } + + /// Send a `loginChatGpt` JSON-RPC request. + pub async fn send_login_chat_gpt_request(&mut self) -> anyhow::Result { + self.send_request("loginChatGpt", None).await + } + + /// Send a `cancelLoginChatGpt` JSON-RPC request. + pub async fn send_cancel_login_chat_gpt_request( + &mut self, + params: CancelLoginChatGptParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("cancelLoginChatGpt", params).await + } + + /// Send a `logoutChatGpt` JSON-RPC request. + pub async fn send_logout_chat_gpt_request(&mut self) -> anyhow::Result { + self.send_request("logoutChatGpt", None).await + } + + /// Send a `fuzzyFileSearch` JSON-RPC request. + pub async fn send_fuzzy_file_search_request( + &mut self, + query: &str, + roots: Vec, + cancellation_token: Option, + ) -> anyhow::Result { + let mut params = serde_json::json!({ + "query": query, + "roots": roots, + }); + if let Some(token) = cancellation_token { + params["cancellationToken"] = serde_json::json!(token); + } + self.send_request("fuzzyFileSearch", Some(params)).await + } + + async 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).await?; + Ok(request_id) + } + + pub async 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, + })) + .await + } + + pub async fn send_notification( + &mut self, + notification: ClientNotification, + ) -> anyhow::Result<()> { + let value = serde_json::to_value(notification)?; + self.send_jsonrpc_message(JSONRPCMessage::Notification(JSONRPCNotification { + jsonrpc: JSONRPC_VERSION.into(), + method: value + .get("method") + .and_then(|m| m.as_str()) + .ok_or_else(|| anyhow::format_err!("notification missing method field"))? + .to_string(), + params: value.get("params").cloned(), + })) + .await + } + + async fn send_jsonrpc_message(&mut self, message: JSONRPCMessage) -> anyhow::Result<()> { + eprintln!("writing message to stdin: {message:?}"); + let payload = serde_json::to_string(&message)?; + self.stdin.write_all(payload.as_bytes()).await?; + self.stdin.write_all(b"\n").await?; + self.stdin.flush().await?; + Ok(()) + } + + async fn read_jsonrpc_message(&mut self) -> anyhow::Result { + let mut line = String::new(); + self.stdout.read_line(&mut line).await?; + let message = serde_json::from_str::(&line)?; + eprintln!("read message from stdout: {message:?}"); + Ok(message) + } + + pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result { + eprintln!("in read_stream_until_request_message()"); + + loop { + let message = self.read_jsonrpc_message().await?; + + 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:?}"); + } + } + } + } + + pub async fn read_stream_until_response_message( + &mut self, + request_id: RequestId, + ) -> anyhow::Result { + eprintln!("in read_stream_until_response_message({request_id:?})"); + + loop { + let message = self.read_jsonrpc_message().await?; + match message { + JSONRPCMessage::Notification(_) => { + eprintln!("notification: {message:?}"); + } + JSONRPCMessage::Request(_) => { + anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}"); + } + JSONRPCMessage::Error(_) => { + anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}"); + } + JSONRPCMessage::Response(jsonrpc_response) => { + if jsonrpc_response.id == request_id { + return Ok(jsonrpc_response); + } + } + } + } + } + + pub async fn read_stream_until_error_message( + &mut self, + request_id: RequestId, + ) -> anyhow::Result { + loop { + let message = self.read_jsonrpc_message().await?; + match message { + JSONRPCMessage::Notification(_) => { + eprintln!("notification: {message:?}"); + } + JSONRPCMessage::Request(_) => { + anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}"); + } + JSONRPCMessage::Response(_) => { + // Keep scanning; we're waiting for an error with matching id. + } + JSONRPCMessage::Error(err) => { + if err.id == request_id { + return Ok(err); + } + } + } + } + } + + pub async fn read_stream_until_notification_message( + &mut self, + method: &str, + ) -> anyhow::Result { + eprintln!("in read_stream_until_notification_message({method})"); + + loop { + let message = self.read_jsonrpc_message().await?; + match message { + JSONRPCMessage::Notification(notification) => { + if notification.method == method { + return Ok(notification); + } + } + JSONRPCMessage::Request(_) => { + anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}"); + } + JSONRPCMessage::Error(_) => { + anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}"); + } + JSONRPCMessage::Response(_) => { + anyhow::bail!("unexpected JSONRPCMessage::Response: {message:?}"); + } + } + } + } +} diff --git a/codex-rs/app-server/tests/common/mock_model_server.rs b/codex-rs/app-server/tests/common/mock_model_server.rs new file mode 100644 index 0000000000..be7f3eb5b3 --- /dev/null +++ b/codex-rs/app-server/tests/common/mock_model_server.rs @@ -0,0 +1,47 @@ +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Respond; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +/// Create a mock server that will provide the responses, in order, for +/// requests to the `/v1/chat/completions` endpoint. +pub async fn create_mock_chat_completions_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/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs new file mode 100644 index 0000000000..9a827fb986 --- /dev/null +++ b/codex-rs/app-server/tests/common/responses.rs @@ -0,0 +1,95 @@ +use serde_json::json; +use std::path::Path; + +pub fn create_shell_sse_response( + command: Vec, + workdir: Option<&Path>, + timeout_ms: Option, + call_id: &str, +) -> 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": call_id, + "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 fn create_apply_patch_sse_response( + patch_content: &str, + call_id: &str, +) -> anyhow::Result { + // Use shell command to call apply_patch with heredoc format + let shell_command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF"); + let tool_call_arguments = serde_json::to_string(&json!({ + "command": ["bash", "-lc", shell_command] + }))?; + + let tool_call = json!({ + "choices": [ + { + "delta": { + "tool_calls": [ + { + "id": call_id, + "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) +} diff --git a/codex-rs/mcp-server/tests/suite/archive_conversation.rs b/codex-rs/app-server/tests/suite/archive_conversation.rs similarity index 98% rename from codex-rs/mcp-server/tests/suite/archive_conversation.rs rename to codex-rs/app-server/tests/suite/archive_conversation.rs index e54a99896c..65a1589763 100644 --- a/codex-rs/mcp-server/tests/suite/archive_conversation.rs +++ b/codex-rs/app-server/tests/suite/archive_conversation.rs @@ -1,12 +1,12 @@ use std::path::Path; +use app_test_support::McpProcess; +use app_test_support::to_response; use codex_core::ARCHIVED_SESSIONS_SUBDIR; use codex_protocol::mcp_protocol::ArchiveConversationParams; use codex_protocol::mcp_protocol::ArchiveConversationResponse; use codex_protocol::mcp_protocol::NewConversationParams; use codex_protocol::mcp_protocol::NewConversationResponse; -use mcp_test_support::McpProcess; -use mcp_test_support::to_response; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use tempfile::TempDir; diff --git a/codex-rs/mcp-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs similarity index 99% rename from codex-rs/mcp-server/tests/suite/auth.rs rename to codex-rs/app-server/tests/suite/auth.rs index 6681fd7546..b19203d880 100644 --- a/codex-rs/mcp-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -1,12 +1,12 @@ use std::path::Path; +use app_test_support::McpProcess; +use app_test_support::to_response; use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::GetAuthStatusParams; use codex_protocol::mcp_protocol::GetAuthStatusResponse; use codex_protocol::mcp_protocol::LoginApiKeyParams; use codex_protocol::mcp_protocol::LoginApiKeyResponse; -use mcp_test_support::McpProcess; -use mcp_test_support::to_response; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use pretty_assertions::assert_eq; diff --git a/codex-rs/mcp-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs similarity index 98% rename from codex-rs/mcp-server/tests/suite/codex_message_processor_flow.rs rename to codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 50a480b4a0..7527411223 100644 --- a/codex-rs/mcp-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -1,5 +1,10 @@ use std::path::Path; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::create_shell_sse_response; +use app_test_support::to_response; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; @@ -16,11 +21,6 @@ use codex_protocol::mcp_protocol::SendUserMessageParams; use codex_protocol::mcp_protocol::SendUserMessageResponse; use codex_protocol::mcp_protocol::SendUserTurnParams; use codex_protocol::mcp_protocol::SendUserTurnResponse; -use mcp_test_support::McpProcess; -use mcp_test_support::create_final_assistant_message_sse_response; -use mcp_test_support::create_mock_chat_completions_server; -use mcp_test_support::create_shell_sse_response; -use mcp_test_support::to_response; use mcp_types::JSONRPCNotification; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; diff --git a/codex-rs/mcp-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs similarity index 98% rename from codex-rs/mcp-server/tests/suite/config.rs rename to codex-rs/app-server/tests/suite/config.rs index da64648c49..13cb6c4a2f 100644 --- a/codex-rs/mcp-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; use std::path::Path; +use app_test_support::McpProcess; +use app_test_support::to_response; use codex_core::protocol::AskForApproval; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; @@ -11,8 +13,6 @@ use codex_protocol::mcp_protocol::Profile; use codex_protocol::mcp_protocol::SandboxSettings; use codex_protocol::mcp_protocol::Tools; use codex_protocol::mcp_protocol::UserSavedConfig; -use mcp_test_support::McpProcess; -use mcp_test_support::to_response; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use pretty_assertions::assert_eq; diff --git a/codex-rs/mcp-server/tests/suite/create_conversation.rs b/codex-rs/app-server/tests/suite/create_conversation.rs similarity index 96% rename from codex-rs/mcp-server/tests/suite/create_conversation.rs rename to codex-rs/app-server/tests/suite/create_conversation.rs index 1b62d01d46..d5896fffcb 100644 --- a/codex-rs/mcp-server/tests/suite/create_conversation.rs +++ b/codex-rs/app-server/tests/suite/create_conversation.rs @@ -1,5 +1,9 @@ use std::path::Path; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::to_response; use codex_protocol::mcp_protocol::AddConversationListenerParams; use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse; use codex_protocol::mcp_protocol::InputItem; @@ -7,10 +11,6 @@ use codex_protocol::mcp_protocol::NewConversationParams; use codex_protocol::mcp_protocol::NewConversationResponse; use codex_protocol::mcp_protocol::SendUserMessageParams; use codex_protocol::mcp_protocol::SendUserMessageResponse; -use mcp_test_support::McpProcess; -use mcp_test_support::create_final_assistant_message_sse_response; -use mcp_test_support::create_mock_chat_completions_server; -use mcp_test_support::to_response; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use pretty_assertions::assert_eq; diff --git a/codex-rs/mcp-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs similarity index 99% rename from codex-rs/mcp-server/tests/suite/fuzzy_file_search.rs rename to codex-rs/app-server/tests/suite/fuzzy_file_search.rs index e4aa0add9c..12cbb7d574 100644 --- a/codex-rs/mcp-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -1,4 +1,4 @@ -use mcp_test_support::McpProcess; +use app_test_support::McpProcess; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use pretty_assertions::assert_eq; diff --git a/codex-rs/mcp-server/tests/suite/interrupt.rs b/codex-rs/app-server/tests/suite/interrupt.rs similarity index 95% rename from codex-rs/mcp-server/tests/suite/interrupt.rs rename to codex-rs/app-server/tests/suite/interrupt.rs index 232b695d1d..087f1b6078 100644 --- a/codex-rs/mcp-server/tests/suite/interrupt.rs +++ b/codex-rs/app-server/tests/suite/interrupt.rs @@ -1,5 +1,5 @@ #![cfg(unix)] -// Support code lives in the `mcp_test_support` crate under tests/common. +// Support code lives in the `app_test_support` crate under tests/common. use std::path::Path; @@ -17,10 +17,10 @@ use mcp_types::RequestId; use tempfile::TempDir; use tokio::time::timeout; -use mcp_test_support::McpProcess; -use mcp_test_support::create_mock_chat_completions_server; -use mcp_test_support::create_shell_sse_response; -use mcp_test_support::to_response; +use app_test_support::McpProcess; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::create_shell_sse_response; +use app_test_support::to_response; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); diff --git a/codex-rs/mcp-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs similarity index 87% rename from codex-rs/mcp-server/tests/suite/list_resume.rs rename to codex-rs/app-server/tests/suite/list_resume.rs index 9302b42990..9e91fdd9cd 100644 --- a/codex-rs/mcp-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -1,13 +1,15 @@ use std::fs; use std::path::Path; +use app_test_support::McpProcess; +use app_test_support::to_response; use codex_protocol::mcp_protocol::ListConversationsParams; use codex_protocol::mcp_protocol::ListConversationsResponse; use codex_protocol::mcp_protocol::NewConversationParams; // reused for overrides shape use codex_protocol::mcp_protocol::ResumeConversationParams; use codex_protocol::mcp_protocol::ResumeConversationResponse; -use mcp_test_support::McpProcess; -use mcp_test_support::to_response; +use codex_protocol::mcp_protocol::ServerNotification; +use codex_protocol::mcp_protocol::SessionConfiguredNotification; use mcp_types::JSONRPCNotification; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; @@ -111,23 +113,28 @@ async fn test_list_and_resume_conversations() { .await .expect("send resumeConversation"); - // Expect a codex/event notification with msg.type == session_configured + // Expect a codex/event notification with msg.type == sessionConfigured let notification: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event"), + mcp.read_stream_until_notification_message("sessionConfigured"), ) .await - .expect("session_configured notification timeout") - .expect("session_configured notification"); - // Basic shape assertion: ensure event type is session_configured - let msg_type = notification - .params - .as_ref() - .and_then(|p| p.get("msg")) - .and_then(|m| m.get("type")) - .and_then(|t| t.as_str()) - .unwrap_or(""); - assert_eq!(msg_type, "session_configured"); + .expect("sessionConfigured notification timeout") + .expect("sessionConfigured notification"); + let session_configured: ServerNotification = notification + .try_into() + .expect("deserialize sessionConfigured notification"); + // Basic shape assertion: ensure event type is sessionConfigured + let ServerNotification::SessionConfigured(SessionConfiguredNotification { + model, + rollout_path, + .. + }) = session_configured + else { + unreachable!("expected sessionConfigured notification"); + }; + assert_eq!(model, "o3"); + assert_eq!(items[0].path.clone(), rollout_path); // Then the response for resumeConversation let resume_resp: JSONRPCResponse = timeout( diff --git a/codex-rs/mcp-server/tests/suite/login.rs b/codex-rs/app-server/tests/suite/login.rs similarity index 98% rename from codex-rs/mcp-server/tests/suite/login.rs rename to codex-rs/app-server/tests/suite/login.rs index 071154c643..475af9ab57 100644 --- a/codex-rs/mcp-server/tests/suite/login.rs +++ b/codex-rs/app-server/tests/suite/login.rs @@ -1,6 +1,8 @@ use std::path::Path; use std::time::Duration; +use app_test_support::McpProcess; +use app_test_support::to_response; use codex_login::login_with_api_key; use codex_protocol::mcp_protocol::CancelLoginChatGptParams; use codex_protocol::mcp_protocol::CancelLoginChatGptResponse; @@ -8,8 +10,6 @@ use codex_protocol::mcp_protocol::GetAuthStatusParams; use codex_protocol::mcp_protocol::GetAuthStatusResponse; use codex_protocol::mcp_protocol::LoginChatGptResponse; use codex_protocol::mcp_protocol::LogoutChatGptResponse; -use mcp_test_support::McpProcess; -use mcp_test_support::to_response; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use tempfile::TempDir; diff --git a/codex-rs/app-server/tests/suite/mod.rs b/codex-rs/app-server/tests/suite/mod.rs new file mode 100644 index 0000000000..78ce310e74 --- /dev/null +++ b/codex-rs/app-server/tests/suite/mod.rs @@ -0,0 +1,13 @@ +mod archive_conversation; +mod auth; +mod codex_message_processor_flow; +mod config; +mod create_conversation; +mod fuzzy_file_search; +mod interrupt; +mod list_resume; +mod login; +mod send_message; +mod set_default_model; +mod user_agent; +mod user_info; diff --git a/codex-rs/mcp-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs similarity index 97% rename from codex-rs/mcp-server/tests/suite/send_message.rs rename to codex-rs/app-server/tests/suite/send_message.rs index 158cb12d1c..81da0096f4 100644 --- a/codex-rs/mcp-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -1,5 +1,9 @@ use std::path::Path; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::to_response; use codex_protocol::mcp_protocol::AddConversationListenerParams; use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse; use codex_protocol::mcp_protocol::ConversationId; @@ -8,10 +12,6 @@ use codex_protocol::mcp_protocol::NewConversationParams; use codex_protocol::mcp_protocol::NewConversationResponse; use codex_protocol::mcp_protocol::SendUserMessageParams; use codex_protocol::mcp_protocol::SendUserMessageResponse; -use mcp_test_support::McpProcess; -use mcp_test_support::create_final_assistant_message_sse_response; -use mcp_test_support::create_mock_chat_completions_server; -use mcp_test_support::to_response; use mcp_types::JSONRPCNotification; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; diff --git a/codex-rs/mcp-server/tests/suite/set_default_model.rs b/codex-rs/app-server/tests/suite/set_default_model.rs similarity index 97% rename from codex-rs/mcp-server/tests/suite/set_default_model.rs rename to codex-rs/app-server/tests/suite/set_default_model.rs index f7e1041fa7..ce0d79d497 100644 --- a/codex-rs/mcp-server/tests/suite/set_default_model.rs +++ b/codex-rs/app-server/tests/suite/set_default_model.rs @@ -1,10 +1,10 @@ use std::path::Path; +use app_test_support::McpProcess; +use app_test_support::to_response; use codex_core::config::ConfigToml; use codex_protocol::mcp_protocol::SetDefaultModelParams; use codex_protocol::mcp_protocol::SetDefaultModelResponse; -use mcp_test_support::McpProcess; -use mcp_test_support::to_response; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use pretty_assertions::assert_eq; diff --git a/codex-rs/mcp-server/tests/suite/user_agent.rs b/codex-rs/app-server/tests/suite/user_agent.rs similarity index 91% rename from codex-rs/mcp-server/tests/suite/user_agent.rs rename to codex-rs/app-server/tests/suite/user_agent.rs index 718e145250..a7f75eab37 100644 --- a/codex-rs/mcp-server/tests/suite/user_agent.rs +++ b/codex-rs/app-server/tests/suite/user_agent.rs @@ -1,6 +1,6 @@ +use app_test_support::McpProcess; +use app_test_support::to_response; use codex_protocol::mcp_protocol::GetUserAgentResponse; -use mcp_test_support::McpProcess; -use mcp_test_support::to_response; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use pretty_assertions::assert_eq; @@ -35,7 +35,7 @@ async fn get_user_agent_returns_current_codex_user_agent() { let os_info = os_info::get(); let user_agent = format!( - "codex_cli_rs/0.0.0 ({} {}; {}) {} (elicitation test; 0.0.0)", + "codex_cli_rs/0.0.0 ({} {}; {}) {} (codex-app-server-tests; 0.1.0)", os_info.os_type(), os_info.version(), os_info.architecture().unwrap_or("unknown"), diff --git a/codex-rs/mcp-server/tests/suite/user_info.rs b/codex-rs/app-server/tests/suite/user_info.rs similarity index 97% rename from codex-rs/mcp-server/tests/suite/user_info.rs rename to codex-rs/app-server/tests/suite/user_info.rs index 7bcb2acc6b..10ca7d330a 100644 --- a/codex-rs/mcp-server/tests/suite/user_info.rs +++ b/codex-rs/app-server/tests/suite/user_info.rs @@ -1,6 +1,8 @@ use std::time::Duration; use anyhow::Context; +use app_test_support::McpProcess; +use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_core::auth::AuthDotJson; @@ -9,8 +11,6 @@ use codex_core::auth::write_auth_json; use codex_core::token_data::IdTokenInfo; use codex_core::token_data::TokenData; use codex_protocol::mcp_protocol::UserInfoResponse; -use mcp_test_support::McpProcess; -use mcp_test_support::to_response; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use pretty_assertions::assert_eq; diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 618f4b7b03..c28b90a2b1 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -18,6 +18,7 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } +codex-app-server = { workspace = true } codex-arg0 = { workspace = true } codex-chatgpt = { workspace = true } codex-common = { workspace = true, features = ["cli"] } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index df0d30aba9..d0d777f568 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -66,6 +66,12 @@ enum Subcommand { /// [experimental] Run Codex as an MCP server and manage MCP servers. Mcp(McpCli), + /// [experimental] Run the Codex MCP server (stdio transport). + McpServer, + + /// [experimental] Run the app server. + AppServer, + /// Run the Protocol stream via stdin/stdout #[clap(visible_alias = "p")] Proto(ProtoCli), @@ -260,10 +266,16 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ); codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?; } + Some(Subcommand::McpServer) => { + codex_mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?; + } Some(Subcommand::Mcp(mut mcp_cli)) => { // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); - mcp_cli.run(codex_linux_sandbox_exe).await?; + mcp_cli.run().await?; + } + Some(Subcommand::AppServer) => { + codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?; } Some(Subcommand::Resume(ResumeCommand { session_id, diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 0cb448d8be..85243a641b 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::path::PathBuf; use anyhow::Context; use anyhow::Result; @@ -28,14 +27,11 @@ pub struct McpCli { pub config_overrides: CliConfigOverrides, #[command(subcommand)] - pub cmd: Option, + pub subcommand: McpSubcommand, } #[derive(Debug, clap::Subcommand)] pub enum McpSubcommand { - /// [experimental] Run the Codex MCP server (stdio transport). - Serve, - /// [experimental] List configured MCP servers. List(ListArgs), @@ -87,17 +83,13 @@ pub struct RemoveArgs { } impl McpCli { - pub async fn run(self, codex_linux_sandbox_exe: Option) -> Result<()> { + pub async fn run(self) -> Result<()> { let McpCli { config_overrides, - cmd, + subcommand, } = self; - let subcommand = cmd.unwrap_or(McpSubcommand::Serve); match subcommand { - McpSubcommand::Serve => { - codex_mcp_server::run_main(codex_linux_sandbox_exe, config_overrides).await?; - } McpSubcommand::List(args) => { run_list(&config_overrides, args)?; } diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 8f0c279058..1291d3ee9f 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -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-server` (or `codex-mcp-server`) - Transport: standard MCP over stdio (JSON‑RPC 2.0, line‑delimited) ## Overview @@ -36,15 +36,17 @@ See code for full type definitions and exact shapes: `protocol/src/mcp_protocol. Run Codex as an MCP server and connect an MCP client: ```bash -codex mcp | your_mcp_client +codex mcp-server | your_mcp_client ``` For a simple inspection UI, you can also try: ```bash -npx @modelcontextprotocol/inspector codex mcp +npx @modelcontextprotocol/inspector codex mcp-server ``` +Use the separate `codex mcp` subcommand to manage configured MCP server launchers in `config.toml`. + ## Conversations Start a new session with optional overrides: diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 9a5fd947d8..484af6d8e8 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -19,9 +19,8 @@ anyhow = { workspace = true } codex-arg0 = { workspace = true } codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } -codex-file-search = { workspace = true } -codex-login = { workspace = true } codex-protocol = { workspace = true } +codex-utils-json-to-toml = { workspace = true } mcp-types = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -34,14 +33,11 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } -toml = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } -uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] assert_cmd = { workspace = true } -base64 = { workspace = true } core_test_support = { workspace = true } mcp_test_support = { workspace = true } os_info = { workspace = true } diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index d90924aa94..3ee1669020 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -2,6 +2,7 @@ use codex_core::protocol::AskForApproval; use codex_protocol::config_types::SandboxMode; +use codex_utils_json_to_toml::json_to_toml; use mcp_types::Tool; use mcp_types::ToolInputSchema; use schemars::JsonSchema; @@ -11,8 +12,6 @@ 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, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "kebab-case")] diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 830bf49247..ffc4b3e362 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -20,13 +20,10 @@ use tracing::error; use tracing::info; use tracing_subscriber::EnvFilter; -mod codex_message_processor; mod codex_tool_config; mod codex_tool_runner; mod error_code; mod exec_approval; -mod fuzzy_file_search; -mod json_to_toml; pub(crate) mod message_processor; mod outgoing_message; mod patch_approval; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 5868d60fc0..addc9572ec 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -1,14 +1,12 @@ use std::collections::HashMap; use std::path::PathBuf; -use crate::codex_message_processor::CodexMessageProcessor; 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::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::OutgoingMessageSender; -use codex_protocol::mcp_protocol::ClientRequest; use codex_protocol::mcp_protocol::ConversationId; use codex_core::AuthManager; @@ -38,7 +36,6 @@ use tokio::sync::Mutex; use tokio::task; pub(crate) struct MessageProcessor { - codex_message_processor: CodexMessageProcessor, outgoing: Arc, initialized: bool, codex_linux_sandbox_exe: Option, @@ -56,16 +53,8 @@ impl MessageProcessor { ) -> Self { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared(config.codex_home.clone()); - let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); - let codex_message_processor = CodexMessageProcessor::new( - auth_manager, - conversation_manager.clone(), - outgoing.clone(), - codex_linux_sandbox_exe.clone(), - config, - ); + let conversation_manager = Arc::new(ConversationManager::new(auth_manager)); Self { - codex_message_processor, outgoing, initialized: false, codex_linux_sandbox_exe, @@ -75,17 +64,6 @@ impl MessageProcessor { } pub(crate) async fn process_request(&mut self, request: JSONRPCRequest) { - if let Ok(request_json) = serde_json::to_value(request.clone()) - && let Ok(codex_request) = serde_json::from_value::(request_json) - { - // If the request is a Codex request, handle it with the Codex - // message processor. - self.codex_message_processor - .process_request(codex_request) - .await; - return; - } - // Hold on to the ID so we can respond. let request_id = request.id.clone(); diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 6f376b1df5..2e350dd9a9 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -3,7 +3,6 @@ use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; use codex_core::protocol::Event; -use codex_protocol::mcp_protocol::ServerNotification; use mcp_types::JSONRPC_VERSION; use mcp_types::JSONRPCError; use mcp_types::JSONRPCErrorError; @@ -125,12 +124,6 @@ impl OutgoingMessageSender { .await; } - pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { - let _ = self - .sender - .send(OutgoingMessage::AppServerNotification(notification)); - } - pub(crate) async fn send_notification(&self, notification: OutgoingNotification) { let outgoing_message = OutgoingMessage::Notification(notification); let _ = self.sender.send(outgoing_message); @@ -146,9 +139,6 @@ impl OutgoingMessageSender { pub(crate) enum OutgoingMessage { Request(OutgoingRequest), Notification(OutgoingNotification), - /// AppServerNotification is specific to the case where this is run as an - /// "app server" as opposed to an MCP server. - AppServerNotification(ServerNotification), Response(OutgoingResponse), Error(OutgoingError), } @@ -172,21 +162,6 @@ impl From for JSONRPCMessage { params, }) } - AppServerNotification(notification) => { - let method = notification.to_string(); - let params = match notification.to_params() { - Ok(params) => Some(params), - Err(err) => { - warn!("failed to serialize notification params: {err}"); - None - } - }; - JSONRPCMessage::Notification(JSONRPCNotification { - jsonrpc: JSONRPC_VERSION.into(), - method, - params, - }) - } Response(OutgoingResponse { id, result }) => { JSONRPCMessage::Response(JSONRPCResponse { jsonrpc: JSONRPC_VERSION.into(), @@ -261,11 +236,9 @@ mod tests { use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::mcp_protocol::ConversationId; - use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::NamedTempFile; - use uuid::Uuid; use super::*; @@ -357,29 +330,4 @@ mod tests { assert_eq!(params.unwrap(), expected_params); Ok(()) } - - #[test] - fn verify_server_notification_serialization() { - let notification = - ServerNotification::LoginChatGptComplete(LoginChatGptCompleteNotification { - login_id: Uuid::nil(), - success: true, - error: None, - }); - - let jsonrpc_notification: JSONRPCMessage = - OutgoingMessage::AppServerNotification(notification).into(); - assert_eq!( - JSONRPCMessage::Notification(JSONRPCNotification { - jsonrpc: "2.0".into(), - method: "loginChatGptComplete".into(), - params: Some(json!({ - "loginId": Uuid::nil(), - "success": true, - })), - }), - jsonrpc_notification, - "ensure the strum macros serialize the method field correctly" - ); - } } diff --git a/codex-rs/mcp-server/tests/common/Cargo.toml b/codex-rs/mcp-server/tests/common/Cargo.toml index e6f7117250..7c2bc2266a 100644 --- a/codex-rs/mcp-server/tests/common/Cargo.toml +++ b/codex-rs/mcp-server/tests/common/Cargo.toml @@ -11,7 +11,6 @@ anyhow = { workspace = true } assert_cmd = { workspace = true } codex-core = { workspace = true } codex-mcp-server = { workspace = true } -codex-protocol = { workspace = true } mcp-types = { workspace = true } os_info = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index f89f72f5a4..a6bc966d7e 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -12,19 +12,6 @@ use tokio::process::ChildStdout; use anyhow::Context; use assert_cmd::prelude::*; use codex_mcp_server::CodexToolCallParam; -use codex_protocol::mcp_protocol::AddConversationListenerParams; -use codex_protocol::mcp_protocol::ArchiveConversationParams; -use codex_protocol::mcp_protocol::CancelLoginChatGptParams; -use codex_protocol::mcp_protocol::GetAuthStatusParams; -use codex_protocol::mcp_protocol::InterruptConversationParams; -use codex_protocol::mcp_protocol::ListConversationsParams; -use codex_protocol::mcp_protocol::LoginApiKeyParams; -use codex_protocol::mcp_protocol::NewConversationParams; -use codex_protocol::mcp_protocol::RemoveConversationListenerParams; -use codex_protocol::mcp_protocol::ResumeConversationParams; -use codex_protocol::mcp_protocol::SendUserMessageParams; -use codex_protocol::mcp_protocol::SendUserTurnParams; -use codex_protocol::mcp_protocol::SetDefaultModelParams; use mcp_types::CallToolRequestParams; use mcp_types::ClientCapabilities; @@ -213,167 +200,6 @@ impl McpProcess { .await } - /// Send a `newConversation` JSON-RPC request. - pub async fn send_new_conversation_request( - &mut self, - params: NewConversationParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("newConversation", params).await - } - - /// Send an `archiveConversation` JSON-RPC request. - pub async fn send_archive_conversation_request( - &mut self, - params: ArchiveConversationParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("archiveConversation", params).await - } - - /// Send an `addConversationListener` JSON-RPC request. - pub async fn send_add_conversation_listener_request( - &mut self, - params: AddConversationListenerParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("addConversationListener", params).await - } - - /// Send a `sendUserMessage` JSON-RPC request with a single text item. - pub async fn send_send_user_message_request( - &mut self, - params: SendUserMessageParams, - ) -> anyhow::Result { - // Wire format expects variants in camelCase; text item uses external tagging. - let params = Some(serde_json::to_value(params)?); - self.send_request("sendUserMessage", params).await - } - - /// Send a `removeConversationListener` JSON-RPC request. - pub async fn send_remove_conversation_listener_request( - &mut self, - params: RemoveConversationListenerParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("removeConversationListener", params) - .await - } - - /// Send a `sendUserTurn` JSON-RPC request. - pub async fn send_send_user_turn_request( - &mut self, - params: SendUserTurnParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("sendUserTurn", params).await - } - - /// Send a `interruptConversation` JSON-RPC request. - pub async fn send_interrupt_conversation_request( - &mut self, - params: InterruptConversationParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("interruptConversation", params).await - } - - /// Send a `getAuthStatus` JSON-RPC request. - pub async fn send_get_auth_status_request( - &mut self, - params: GetAuthStatusParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("getAuthStatus", params).await - } - - /// Send a `getUserSavedConfig` JSON-RPC request. - pub async fn send_get_user_saved_config_request(&mut self) -> anyhow::Result { - self.send_request("getUserSavedConfig", None).await - } - - /// Send a `getUserAgent` JSON-RPC request. - pub async fn send_get_user_agent_request(&mut self) -> anyhow::Result { - self.send_request("getUserAgent", None).await - } - - /// Send a `userInfo` JSON-RPC request. - pub async fn send_user_info_request(&mut self) -> anyhow::Result { - self.send_request("userInfo", None).await - } - - /// Send a `setDefaultModel` JSON-RPC request. - pub async fn send_set_default_model_request( - &mut self, - params: SetDefaultModelParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("setDefaultModel", params).await - } - - /// Send a `listConversations` JSON-RPC request. - pub async fn send_list_conversations_request( - &mut self, - params: ListConversationsParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("listConversations", params).await - } - - /// Send a `resumeConversation` JSON-RPC request. - pub async fn send_resume_conversation_request( - &mut self, - params: ResumeConversationParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("resumeConversation", params).await - } - - /// Send a `loginApiKey` JSON-RPC request. - pub async fn send_login_api_key_request( - &mut self, - params: LoginApiKeyParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("loginApiKey", params).await - } - - /// Send a `loginChatGpt` JSON-RPC request. - pub async fn send_login_chat_gpt_request(&mut self) -> anyhow::Result { - self.send_request("loginChatGpt", None).await - } - - /// Send a `cancelLoginChatGpt` JSON-RPC request. - pub async fn send_cancel_login_chat_gpt_request( - &mut self, - params: CancelLoginChatGptParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("cancelLoginChatGpt", params).await - } - - /// Send a `logoutChatGpt` JSON-RPC request. - pub async fn send_logout_chat_gpt_request(&mut self) -> anyhow::Result { - self.send_request("logoutChatGpt", None).await - } - - /// Send a `fuzzyFileSearch` JSON-RPC request. - pub async fn send_fuzzy_file_search_request( - &mut self, - query: &str, - roots: Vec, - cancellation_token: Option, - ) -> anyhow::Result { - let mut params = serde_json::json!({ - "query": query, - "roots": roots, - }); - if let Some(token) = cancellation_token { - params["cancellationToken"] = serde_json::json!(token); - } - self.send_request("fuzzyFileSearch", Some(params)).await - } - async fn send_request( &mut self, method: &str, @@ -471,58 +297,6 @@ impl McpProcess { } } - pub async fn read_stream_until_error_message( - &mut self, - request_id: RequestId, - ) -> anyhow::Result { - loop { - let message = self.read_jsonrpc_message().await?; - match message { - JSONRPCMessage::Notification(_) => { - eprintln!("notification: {message:?}"); - } - JSONRPCMessage::Request(_) => { - anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}"); - } - JSONRPCMessage::Response(_) => { - // Keep scanning; we're waiting for an error with matching id. - } - JSONRPCMessage::Error(err) => { - if err.id == request_id { - return Ok(err); - } - } - } - } - } - - pub async fn read_stream_until_notification_message( - &mut self, - method: &str, - ) -> anyhow::Result { - eprintln!("in read_stream_until_notification_message({method})"); - - loop { - let message = self.read_jsonrpc_message().await?; - match message { - JSONRPCMessage::Notification(notification) => { - if notification.method == method { - return Ok(notification); - } - } - JSONRPCMessage::Request(_) => { - anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}"); - } - JSONRPCMessage::Error(_) => { - anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}"); - } - JSONRPCMessage::Response(_) => { - anyhow::bail!("unexpected JSONRPCMessage::Response: {message:?}"); - } - } - } - } - /// Reads notifications until a legacy TaskComplete event is observed: /// Method "codex/event" with params.msg.type == "task_complete". pub async fn read_stream_until_legacy_task_complete_notification( diff --git a/codex-rs/mcp-server/tests/suite/mod.rs b/codex-rs/mcp-server/tests/suite/mod.rs index 4a3a91206f..6b50853b16 100644 --- a/codex-rs/mcp-server/tests/suite/mod.rs +++ b/codex-rs/mcp-server/tests/suite/mod.rs @@ -1,15 +1 @@ -// Aggregates all former standalone integration tests as modules. -mod archive_conversation; -mod auth; -mod codex_message_processor_flow; mod codex_tool; -mod config; -mod create_conversation; -mod fuzzy_file_search; -mod interrupt; -mod list_resume; -mod login; -mod send_message; -mod set_default_model; -mod user_agent; -mod user_info; diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index aaf820965d..12c0b7a1aa 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -21,6 +21,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { codex_protocol::mcp_protocol::InputItem::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ClientRequest::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ServerRequest::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::InitializeResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::NewConversationResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ListConversationsResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ResumeConversationResponse::export_all_to(out_dir)?; @@ -50,6 +51,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { // All notification types reachable from this enum will be generated by // induction, so they do not need to be listed individually. codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ClientNotification::export_all_to(out_dir)?; generate_index_ts(out_dir)?; diff --git a/codex-rs/protocol/README.md b/codex-rs/protocol/README.md index 384d0b4859..7120d9f3b3 100644 --- a/codex-rs/protocol/README.md +++ b/codex-rs/protocol/README.md @@ -1,6 +1,6 @@ # codex-protocol -This crate defines the "types" for the protocol used by Codex CLI, which includes both "internal types" for communication between `codex-core` and `codex-tui`, as well as "external types" used with `codex mcp`. +This crate defines the "types" for the protocol used by Codex CLI, which includes both "internal types" for communication between `codex-core` and `codex-tui`, as well as "external types" used with `codex app-server`. This crate should have minimal dependencies. diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index ee6a56a86f..107055f6e0 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -12,6 +12,7 @@ use crate::protocol::FileChange; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::TurnAbortReason; +use mcp_types::JSONRPCNotification; use mcp_types::RequestId; use serde::Deserialize; use serde::Serialize; @@ -92,6 +93,11 @@ pub enum AuthMode { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ClientRequest { + Initialize { + #[serde(rename = "id")] + request_id: RequestId, + params: InitializeParams, + }, NewConversation { #[serde(rename = "id")] request_id: RequestId, @@ -197,6 +203,27 @@ pub enum ClientRequest { }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[serde(rename_all = "camelCase")] +pub struct InitializeParams { + pub client_info: ClientInfo, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[serde(rename_all = "camelCase")] +pub struct ClientInfo { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + pub version: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResponse { + pub user_agent: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] #[serde(rename_all = "camelCase")] pub struct NewConversationParams { @@ -702,6 +729,33 @@ pub struct LoginChatGptCompleteNotification { pub error: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionConfiguredNotification { + /// Name left as session_id instead of conversation_id for backwards compatibility. + pub session_id: ConversationId, + + /// Tell the client what model is being queried. + pub model: String, + + /// The effort the model is putting into reasoning about the user's request. + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + + /// Identifier of the history log file (inode on Unix, 0 otherwise). + pub history_log_id: u64, + + /// Current number of entries in the history log. + pub history_entry_count: usize, + + /// Optional initial messages (as events) for resumed sessions. + /// When present, UIs can use these to seed the history. + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_messages: Option>, + + pub rollout_path: PathBuf, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct AuthStatusChangeNotification { @@ -710,7 +764,8 @@ pub struct AuthStatusChangeNotification { pub auth_method: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS, Display)] +/// Notification sent from the server to the client. +#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] #[serde(tag = "method", content = "params", rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum ServerNotification { @@ -719,6 +774,9 @@ pub enum ServerNotification { /// ChatGPT login flow completed LoginChatGptComplete(LoginChatGptCompleteNotification), + + /// The special session configured event for a new or resumed conversation. + SessionConfigured(SessionConfiguredNotification), } impl ServerNotification { @@ -726,10 +784,27 @@ impl ServerNotification { match self { ServerNotification::AuthStatusChange(params) => serde_json::to_value(params), ServerNotification::LoginChatGptComplete(params) => serde_json::to_value(params), + ServerNotification::SessionConfigured(params) => serde_json::to_value(params), } } } +impl TryFrom for ServerNotification { + type Error = serde_json::Error; + + fn try_from(value: JSONRPCNotification) -> Result { + serde_json::from_value(serde_json::to_value(value)?) + } +} + +/// Notification sent from the client to the server. +#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] +#[serde(tag = "method", content = "params", rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum ClientNotification { + Initialized, +} + #[cfg(test)] mod tests { use super::*; @@ -795,4 +870,17 @@ mod tests { ); Ok(()) } + + #[test] + fn serialize_client_notification() -> Result<()> { + let notification = ClientNotification::Initialized; + // Note there is no "params" field for this notification. + assert_eq!( + json!({ + "method": "initialized", + }), + serde_json::to_value(¬ification)?, + ); + Ok(()) + } } diff --git a/codex-rs/utils/json-to-toml/Cargo.toml b/codex-rs/utils/json-to-toml/Cargo.toml new file mode 100644 index 0000000000..a665724d78 --- /dev/null +++ b/codex-rs/utils/json-to-toml/Cargo.toml @@ -0,0 +1,14 @@ +[package] +edition.workspace = true +name = "codex-utils-json-to-toml" +version.workspace = true + +[dependencies] +serde_json = { workspace = true } +toml = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } + +[lints] +workspace = true diff --git a/codex-rs/mcp-server/src/json_to_toml.rs b/codex-rs/utils/json-to-toml/src/lib.rs similarity index 97% rename from codex-rs/mcp-server/src/json_to_toml.rs rename to codex-rs/utils/json-to-toml/src/lib.rs index 04f1f30615..43b7e06e06 100644 --- a/codex-rs/mcp-server/src/json_to_toml.rs +++ b/codex-rs/utils/json-to-toml/src/lib.rs @@ -2,7 +2,7 @@ use serde_json::Value as JsonValue; use toml::Value as TomlValue; /// Convert a `serde_json::Value` into a semantically equivalent `toml::Value`. -pub(crate) fn json_to_toml(v: JsonValue) -> TomlValue { +pub fn json_to_toml(v: JsonValue) -> TomlValue { match v { JsonValue::Null => TomlValue::String(String::new()), JsonValue::Bool(b) => TomlValue::Boolean(b), diff --git a/docs/advanced.md b/docs/advanced.md index 4edca7646f..869a5fb533 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -74,13 +74,13 @@ env = { "API_KEY" = "value" } ## Using Codex as an MCP Server -The Codex CLI can also be run as an MCP _server_ via `codex mcp`. For example, you can use `codex mcp` to make Codex available as a tool inside of a multi-agent framework like the OpenAI [Agents SDK](https://platform.openai.com/docs/guides/agents). +The Codex CLI can also be run as an MCP _server_ via `codex mcp-server`. For example, you can use `codex mcp-server` to make Codex available as a tool inside of a multi-agent framework like the OpenAI [Agents SDK](https://platform.openai.com/docs/guides/agents). Use `codex mcp` separately to add/list/get/remove MCP server launchers in your configuration. ### Codex MCP Server Quickstart You can launch a Codex MCP server with the [Model Context Protocol Inspector](https://modelcontextprotocol.io/legacy/tools/inspector): ``` bash -npx @modelcontextprotocol/inspector codex mcp +npx @modelcontextprotocol/inspector codex mcp-server ``` Send a `tools/list` request and you will see that there are two tools available: @@ -109,7 +109,7 @@ Property | Type | Description > [!TIP] > Codex often takes a few minutes to run. To accommodate this, adjust the MCP inspector's Request and Total timeouts to 600000ms (10 minutes) under ⛭ Configuration. -Use the MCP inspector and `codex mcp` to build a simple tic-tac-toe game with the following settings: +Use the MCP inspector and `codex mcp-server` to build a simple tic-tac-toe game with the following settings: **approval-policy:** never