diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f2f865b0..0a4d8797 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -562,6 +562,8 @@ version = "0.1.0" dependencies = [ "codex-core", "mcp-types", + "pretty_assertions", + "schemars", "serde", "serde_json", "tokio", @@ -934,6 +936,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + [[package]] name = "either" version = "1.15.0" @@ -2824,6 +2832,30 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.100", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2882,6 +2914,17 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "serde_json" version = "1.0.140" diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 258a37aa..fdd2a304 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -4,19 +4,9 @@ version = "0.1.0" edition = "2021" [dependencies] -# -# codex-core contains optional functionality that is gated behind the "cli" -# feature. Unfortunately there is an unconditional reference to a module that -# is only compiled when the feature is enabled, which breaks the build when -# the default (no-feature) variant is used. -# -# We therefore explicitly enable the "cli" feature when codex-mcp-server pulls -# in codex-core so that the required symbols are present. This does _not_ -# change the public API of codex-core – it merely opts into compiling the -# extra, feature-gated source files so the build succeeds. -# codex-core = { path = "../core", features = ["cli"] } mcp-types = { path = "../mcp-types" } +schemars = "0.8.22" serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = { version = "0.1.41", features = ["log"] } @@ -28,3 +18,6 @@ tokio = { version = "1", features = [ "rt-multi-thread", "signal", ] } + +[dev-dependencies] +pretty_assertions = "1.4.1" diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs new file mode 100644 index 00000000..aa1a620d --- /dev/null +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -0,0 +1,244 @@ +//! Configuration object accepted by the `codex` MCP tool-call. + +use std::path::PathBuf; + +use mcp_types::Tool; +use mcp_types::ToolInputSchema; +use schemars::r#gen::SchemaSettings; +use schemars::JsonSchema; +use serde::Deserialize; + +use codex_core::protocol::AskForApproval; +use codex_core::protocol::SandboxPolicy; + +/// Client-supplied configuration for a `codex` tool-call. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct CodexToolCallParam { + /// The *initial user prompt* to start the Codex conversation. + pub prompt: String, + + /// Optional override for the model name (e.g. "o3", "o4-mini") + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Working directory for the session. If relative, it is resolved against + /// the server process's current working directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + + /// Execution approval policy expressed as the kebab-case variant name + /// (`unless-allow-listed`, `auto-edit`, `on-failure`, `never`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + + /// Sandbox permissions using the same string values accepted by the CLI + /// (e.g. "disk-write-cwd", "network-full-access"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox_permissions: Option>, + + /// Disable server-side response storage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disable_response_storage: Option, + // Custom system instructions. + // #[serde(default, skip_serializing_if = "Option::is_none")] + // pub instructions: Option, +} + +// Create custom enums for use with `CodexToolCallApprovalPolicy` where we +// intentionally exclude docstrings from the generated schema because they +// introduce anyOf in the the generated JSON schema, which makes it more complex +// without adding any real value since we aspire to use self-descriptive names. + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum CodexToolCallApprovalPolicy { + AutoEdit, + UnlessAllowListed, + OnFailure, + Never, +} + +impl From for AskForApproval { + fn from(value: CodexToolCallApprovalPolicy) -> Self { + match value { + CodexToolCallApprovalPolicy::AutoEdit => AskForApproval::AutoEdit, + CodexToolCallApprovalPolicy::UnlessAllowListed => AskForApproval::UnlessAllowListed, + CodexToolCallApprovalPolicy::OnFailure => AskForApproval::OnFailure, + CodexToolCallApprovalPolicy::Never => AskForApproval::Never, + } + } +} + +// TODO: Support additional writable folders via a separate property on +// CodexToolCallParam. + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum CodexToolCallSandboxPermission { + DiskFullReadAccess, + DiskWriteCwd, + DiskWritePlatformUserTempFolder, + DiskWritePlatformGlobalTempFolder, + DiskFullWriteAccess, + NetworkFullAccess, +} + +impl From for codex_core::protocol::SandboxPermission { + fn from(value: CodexToolCallSandboxPermission) -> Self { + match value { + CodexToolCallSandboxPermission::DiskFullReadAccess => { + codex_core::protocol::SandboxPermission::DiskFullReadAccess + } + CodexToolCallSandboxPermission::DiskWriteCwd => { + codex_core::protocol::SandboxPermission::DiskWriteCwd + } + CodexToolCallSandboxPermission::DiskWritePlatformUserTempFolder => { + codex_core::protocol::SandboxPermission::DiskWritePlatformUserTempFolder + } + CodexToolCallSandboxPermission::DiskWritePlatformGlobalTempFolder => { + codex_core::protocol::SandboxPermission::DiskWritePlatformGlobalTempFolder + } + CodexToolCallSandboxPermission::DiskFullWriteAccess => { + codex_core::protocol::SandboxPermission::DiskFullWriteAccess + } + CodexToolCallSandboxPermission::NetworkFullAccess => { + codex_core::protocol::SandboxPermission::NetworkFullAccess + } + } + } +} + +pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool { + let schema = SchemaSettings::draft2019_09() + .with(|s| { + s.inline_subschemas = true; + s.option_add_null_type = false + }) + .into_generator() + .into_root_schema_for::(); + let schema_value = + serde_json::to_value(&schema).expect("Codex tool schema should serialise to JSON"); + + let tool_input_schema = + serde_json::from_value::(schema_value).unwrap_or_else(|e| { + panic!("failed to create Tool from schema: {e}"); + }); + Tool { + name: "codex".to_string(), + input_schema: tool_input_schema, + description: Some( + "Run a Codex session. Accepts configuration parameters matching the Codex Config struct." + .to_string(), + ), + annotations: None, + } +} + +impl CodexToolCallParam { + /// Returns the initial user prompt to start the Codex conversation and the + /// Config. + pub fn into_config(self) -> std::io::Result<(String, codex_core::config::Config)> { + let Self { + prompt, + model, + cwd, + approval_policy, + sandbox_permissions, + disable_response_storage, + } = self; + let sandbox_policy = sandbox_permissions.map(|perms| { + SandboxPolicy::from(perms.into_iter().map(Into::into).collect::>()) + }); + + // Build ConfigOverrides recognised by codex-core. + let overrides = codex_core::config::ConfigOverrides { + model, + cwd: cwd.map(PathBuf::from), + approval_policy: approval_policy.map(Into::into), + sandbox_policy, + disable_response_storage, + }; + + let cfg = codex_core::config::Config::load_with_overrides(overrides)?; + + Ok((prompt, cfg)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + /// We include a test to verify the exact JSON schema as "executable + /// documentation" for the schema. When can track changes to this test as a + /// way to audit changes to the generated schema. + /// + /// Seeing the fully expanded schema makes it easier to casually verify that + /// the generated JSON for enum types such as "approval-policy" is compact. + /// Ideally, modelcontextprotocol/inspector would provide a simpler UI for + /// enum fields versus open string fields to take advantage of this. + /// + /// As of 2025-05-04, there is an open PR for this: + /// https://github.com/modelcontextprotocol/inspector/pull/196 + #[test] + fn verify_codex_tool_json_schema() { + let tool = create_tool_for_codex_tool_call_param(); + let tool_json = serde_json::to_value(&tool).expect("tool serializes"); + let expected_tool_json = serde_json::json!({ + "name": "codex", + "description": "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.", + "inputSchema": { + "type": "object", + "properties": { + "approval-policy": { + "description": "Execution approval policy expressed as the kebab-case variant name (`unless-allow-listed`, `auto-edit`, `on-failure`, `never`).", + "enum": [ + "auto-edit", + "unless-allow-listed", + "on-failure", + "never" + ], + "type": "string" + }, + "cwd": { + "description": "Working directory for the session. If relative, it is resolved against the server process's current working directory.", + "type": "string" + }, + "disable-response-storage": { + "description": "Disable server-side response storage.", + "type": "boolean" + }, + "model": { + "description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\")", + "type": "string" + }, + "prompt": { + "description": "The *initial user prompt* to start the Codex conversation.", + "type": "string" + }, + "sandbox-permissions": { + "description": "Sandbox permissions using the same string values accepted by the CLI (e.g. \"disk-write-cwd\", \"network-full-access\").", + "items": { + "enum": [ + "disk-full-read-access", + "disk-write-cwd", + "disk-write-platform-user-temp-folder", + "disk-write-platform-global-temp-folder", + "disk-full-write-access", + "network-full-access" + ], + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "prompt" + ] + } + }); + assert_eq!(expected_tool_json, tool_json); + } +} diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs new file mode 100644 index 00000000..c35b855c --- /dev/null +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -0,0 +1,181 @@ +//! Asynchronous worker that executes a **Codex** tool-call inside a spawned +//! Tokio task. Separated from `message_processor.rs` to keep that file small +//! and to make future feature-growth easier to manage. + +use codex_core::codex_wrapper::init_codex; +use codex_core::config::Config as CodexConfig; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::InputItem; +use codex_core::protocol::Op; +use mcp_types::CallToolResult; +use mcp_types::CallToolResultContent; +use mcp_types::JSONRPCMessage; +use mcp_types::JSONRPCResponse; +use mcp_types::RequestId; +use mcp_types::TextContent; +use mcp_types::JSONRPC_VERSION; +use tokio::sync::mpsc::Sender; + +/// Convert a Codex [`Event`] to an MCP notification. +fn codex_event_to_notification(event: &Event) -> JSONRPCMessage { + JSONRPCMessage::Notification(mcp_types::JSONRPCNotification { + jsonrpc: JSONRPC_VERSION.into(), + method: "codex/event".into(), + params: Some(serde_json::to_value(event).expect("Event must serialize")), + }) +} + +/// Run a complete Codex session and stream events back to the client. +/// +/// On completion (success or error) the function sends the appropriate +/// `tools/call` response so the LLM can continue the conversation. +pub async fn run_codex_tool_session( + id: RequestId, + initial_prompt: String, + config: CodexConfig, + outgoing: Sender, +) { + let (codex, first_event, _ctrl_c) = match init_codex(config).await { + Ok(res) => res, + Err(e) => { + let result = CallToolResult { + content: vec![CallToolResultContent::TextContent(TextContent { + r#type: "text".to_string(), + text: format!("Failed to start Codex session: {e}"), + annotations: None, + })], + is_error: Some(true), + }; + let _ = outgoing + .send(JSONRPCMessage::Response(JSONRPCResponse { + jsonrpc: JSONRPC_VERSION.into(), + id, + result: result.into(), + })) + .await; + return; + } + }; + + // Send initial SessionConfigured event. + let _ = outgoing + .send(codex_event_to_notification(&first_event)) + .await; + + if let Err(e) = codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: initial_prompt.clone(), + }], + }) + .await + { + tracing::error!("Failed to submit initial prompt: {e}"); + } + + let mut last_agent_message: Option = None; + + // Stream events until the task needs to pause for user interaction or + // completes. + loop { + match codex.next_event().await { + Ok(event) => { + let _ = outgoing.send(codex_event_to_notification(&event)).await; + + match &event.msg { + EventMsg::AgentMessage { message } => { + last_agent_message = Some(message.clone()); + } + EventMsg::ExecApprovalRequest { .. } => { + let result = CallToolResult { + content: vec![CallToolResultContent::TextContent(TextContent { + r#type: "text".to_string(), + text: "EXEC_APPROVAL_REQUIRED".to_string(), + annotations: None, + })], + is_error: None, + }; + let _ = outgoing + .send(JSONRPCMessage::Response(JSONRPCResponse { + jsonrpc: JSONRPC_VERSION.into(), + id: id.clone(), + result: result.into(), + })) + .await; + break; + } + EventMsg::ApplyPatchApprovalRequest { .. } => { + let result = CallToolResult { + content: vec![CallToolResultContent::TextContent(TextContent { + r#type: "text".to_string(), + text: "PATCH_APPROVAL_REQUIRED".to_string(), + annotations: None, + })], + is_error: None, + }; + let _ = outgoing + .send(JSONRPCMessage::Response(JSONRPCResponse { + jsonrpc: JSONRPC_VERSION.into(), + id: id.clone(), + result: result.into(), + })) + .await; + break; + } + EventMsg::TaskComplete => { + let result = if let Some(msg) = last_agent_message { + CallToolResult { + content: vec![CallToolResultContent::TextContent(TextContent { + r#type: "text".to_string(), + text: msg, + annotations: None, + })], + is_error: None, + } + } else { + CallToolResult { + content: vec![CallToolResultContent::TextContent(TextContent { + r#type: "text".to_string(), + text: String::new(), + annotations: None, + })], + is_error: None, + } + }; + let _ = outgoing + .send(JSONRPCMessage::Response(JSONRPCResponse { + jsonrpc: JSONRPC_VERSION.into(), + id: id.clone(), + result: result.into(), + })) + .await; + break; + } + EventMsg::SessionConfigured { .. } => { + tracing::error!("unexpected SessionConfigured event"); + } + _ => {} + } + } + Err(e) => { + let result = CallToolResult { + content: vec![CallToolResultContent::TextContent(TextContent { + r#type: "text".to_string(), + text: format!("Codex runtime error: {e}"), + annotations: None, + })], + is_error: Some(true), + }; + let _ = outgoing + .send(JSONRPCMessage::Response(JSONRPCResponse { + jsonrpc: JSONRPC_VERSION.into(), + id: id.clone(), + result: result.into(), + })) + .await; + break; + } + } + } +} diff --git a/codex-rs/mcp-server/src/main.rs b/codex-rs/mcp-server/src/main.rs index b0fb7fec..87e8d7bb 100644 --- a/codex-rs/mcp-server/src/main.rs +++ b/codex-rs/mcp-server/src/main.rs @@ -1,4 +1,5 @@ //! Prototype MCP server. +#![deny(clippy::print_stdout, clippy::print_stderr)] use std::io::Result as IoResult; @@ -12,7 +13,10 @@ use tracing::debug; use tracing::error; use tracing::info; +mod codex_tool_config; +mod codex_tool_runner; mod message_processor; + use crate::message_processor::MessageProcessor; /// Size of the bounded channels used to communicate between tasks. The value diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 6fcdc75d..5fa2085a 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -1,6 +1,9 @@ -//! Very small proof-of-concept request router for the MCP prototype server. +use crate::codex_tool_config::create_tool_for_codex_tool_call_param; +use crate::codex_tool_config::CodexToolCallParam; +use codex_core::config::Config as CodexConfig; use mcp_types::CallToolRequestParams; +use mcp_types::CallToolResult; use mcp_types::CallToolResultContent; use mcp_types::ClientRequest; use mcp_types::JSONRPCBatchRequest; @@ -17,11 +20,10 @@ use mcp_types::RequestId; use mcp_types::ServerCapabilitiesTools; use mcp_types::ServerNotification; use mcp_types::TextContent; -use mcp_types::Tool; -use mcp_types::ToolInputSchema; use mcp_types::JSONRPC_VERSION; use serde_json::json; use tokio::sync::mpsc; +use tokio::task; pub(crate) struct MessageProcessor { outgoing: mpsc::Sender, @@ -303,21 +305,7 @@ impl MessageProcessor { ) { tracing::trace!("tools/list -> {params:?}"); let result = ListToolsResult { - tools: vec![Tool { - name: "echo".to_string(), - input_schema: ToolInputSchema { - r#type: "object".to_string(), - properties: Some(json!({ - "input": { - "type": "string", - "description": "The input to echo back" - } - })), - required: Some(vec!["input".to_string()]), - }, - description: Some("Echoes the request back".to_string()), - annotations: None, - }], + tools: vec![create_tool_for_codex_tool_call_param()], next_cursor: None, }; @@ -331,26 +319,80 @@ impl MessageProcessor { ) { tracing::info!("tools/call -> params: {:?}", params); let CallToolRequestParams { name, arguments } = params; - match name.as_str() { - "echo" => { - let result = mcp_types::CallToolResult { + + // We only support the "codex" tool for now. + if name != "codex" { + // Tool not found – return error result so the LLM can react. + let result = CallToolResult { + content: vec![CallToolResultContent::TextContent(TextContent { + r#type: "text".to_string(), + text: format!("Unknown tool '{name}'"), + annotations: None, + })], + is_error: Some(true), + }; + self.send_response::(id, result); + return; + } + + let (initial_prompt, config): (String, CodexConfig) = match arguments { + Some(json_val) => match serde_json::from_value::(json_val) { + Ok(tool_cfg) => match tool_cfg.into_config() { + Ok(cfg) => cfg, + Err(e) => { + let result = CallToolResult { + content: vec![CallToolResultContent::TextContent(TextContent { + r#type: "text".to_owned(), + text: format!( + "Failed to load Codex configuration from overrides: {e}" + ), + annotations: None, + })], + is_error: Some(true), + }; + self.send_response::(id, result); + return; + } + }, + Err(e) => { + let result = CallToolResult { + content: vec![CallToolResultContent::TextContent(TextContent { + r#type: "text".to_owned(), + text: format!("Failed to parse configuration for Codex tool: {e}"), + annotations: None, + })], + is_error: Some(true), + }; + self.send_response::(id, result); + return; + } + }, + None => { + let result = CallToolResult { content: vec![CallToolResultContent::TextContent(TextContent { r#type: "text".to_string(), - text: format!("Echo: {arguments:?}"), + text: + "Missing arguments for codex tool-call; the `prompt` field is required." + .to_string(), annotations: None, })], - is_error: None, - }; - self.send_response::(id, result); - } - _ => { - let result = mcp_types::CallToolResult { - content: vec![], is_error: Some(true), }; self.send_response::(id, result); + return; } - } + }; + + // Clone outgoing sender to move into async task. + let outgoing = self.outgoing.clone(); + + // Spawn an async task to handle the Codex session so that we do not + // block the synchronous message-processing loop. + task::spawn(async move { + // Run the Codex session and stream events back to the client. + crate::codex_tool_runner::run_codex_tool_session(id, initial_prompt, config, outgoing) + .await; + }); } fn handle_set_level(