agcodex-mcp-server 0.1.0

Model Context Protocol server mode for AGCodex
Documentation
//! Configuration object accepted by the `codex` MCP tool-call.

use agcodex_core::protocol::AskForApproval;
use agcodex_mcp_types::Tool;
use agcodex_mcp_types::ToolInputSchema;
use agcodex_protocol::config_types::SandboxMode;
use schemars::JsonSchema;
use schemars::generate::SchemaSettings;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;

use crate::json_to_toml::json_to_toml;

/// Client-supplied configuration for a `codex` tool-call.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "kebab-case")]
pub 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<String>,

    /// Configuration profile from config.toml to specify default options.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub profile: Option<String>,

    /// 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<String>,

    /// Approval policy for shell commands generated by the model:
    /// `untrusted`, `on-failure`, `on-request`, `never`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approval_policy: Option<CodexToolCallApprovalPolicy>,

    /// Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sandbox: Option<CodexToolCallSandboxMode>,

    /// Individual config settings that will override what is in
    /// CODEX_HOME/config.toml.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub config: Option<HashMap<String, serde_json::Value>>,

    /// The set of instructions to use instead of the default ones.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_instructions: Option<String>,

    /// Whether to include the plan tool in the conversation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub include_plan_tool: Option<bool>,
}

/// Custom enum mirroring [`AskForApproval`], but has an extra dependency on
/// [`JsonSchema`].
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum CodexToolCallApprovalPolicy {
    Untrusted,
    OnFailure,
    OnRequest,
    Never,
}

impl From<CodexToolCallApprovalPolicy> for AskForApproval {
    fn from(value: CodexToolCallApprovalPolicy) -> Self {
        match value {
            CodexToolCallApprovalPolicy::Untrusted => AskForApproval::UnlessTrusted,
            CodexToolCallApprovalPolicy::OnFailure => AskForApproval::OnFailure,
            CodexToolCallApprovalPolicy::OnRequest => AskForApproval::OnRequest,
            CodexToolCallApprovalPolicy::Never => AskForApproval::Never,
        }
    }
}

/// Custom enum mirroring [`SandboxMode`] from config_types.rs, but with
/// `JsonSchema` support.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum CodexToolCallSandboxMode {
    ReadOnly,
    WorkspaceWrite,
    DangerFullAccess,
}

impl From<CodexToolCallSandboxMode> for SandboxMode {
    fn from(value: CodexToolCallSandboxMode) -> Self {
        match value {
            CodexToolCallSandboxMode::ReadOnly => SandboxMode::ReadOnly,
            CodexToolCallSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite,
            CodexToolCallSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess,
        }
    }
}

/// Builds a `Tool` definition (JSON schema etc.) for the Codex tool-call.
pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool {
    let schema = SchemaSettings::draft2019_09()
        .with(|s| {
            s.inline_subschemas = true;
        })
        .into_generator()
        .into_root_schema_for::<CodexToolCallParam>();

    #[expect(clippy::expect_used)]
    let schema_value =
        serde_json::to_value(&schema).expect("Codex tool schema should serialise to JSON");

    let tool_input_schema =
        serde_json::from_value::<ToolInputSchema>(schema_value).unwrap_or_else(|e| {
            panic!("failed to create Tool from schema: {e}");
        });

    Tool {
        name: "agcodex".to_string(),
        title: Some("Codex".to_string()),
        input_schema: tool_input_schema,
        // TODO(mbolin): This should be defined.
        output_schema: None,
        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
    /// effective Config object generated from the supplied parameters.
    pub fn into_config(
        self,
        codex_linux_sandbox_exe: Option<PathBuf>,
    ) -> std::io::Result<(String, agcodex_core::config::Config)> {
        let Self {
            prompt,
            model,
            profile,
            cwd,
            approval_policy,
            sandbox,
            config: cli_overrides,
            base_instructions,
            include_plan_tool,
        } = self;

        // Build the `ConfigOverrides` recognized by codex-core.
        let overrides = agcodex_core::config::ConfigOverrides {
            model,
            config_profile: profile,
            cwd: cwd.map(PathBuf::from),
            approval_policy: approval_policy.map(Into::into),
            sandbox_mode: sandbox.map(Into::into),
            model_provider: None,
            codex_linux_sandbox_exe,
            base_instructions,
            include_plan_tool,
            include_apply_patch_tool: None,
            disable_response_storage: None,
            show_raw_agent_reasoning: None,
        };

        let cli_overrides = cli_overrides
            .unwrap_or_default()
            .into_iter()
            .map(|(k, v)| (k, json_to_toml(v)))
            .collect();

        let cfg = agcodex_core::config::Config::load_with_cli_overrides(cli_overrides, overrides)?;

        Ok((prompt, cfg))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CodexToolCallReplyParam {
    /// The *session id* for this conversation.
    pub session_id: String,

    /// The *next user prompt* to continue the Codex conversation.
    pub prompt: String,
}

/// Builds a `Tool` definition for the `codex-reply` tool-call.
pub(crate) fn create_tool_for_codex_tool_call_reply_param() -> Tool {
    let schema = SchemaSettings::draft2019_09()
        .with(|s| {
            s.inline_subschemas = true;
        })
        .into_generator()
        .into_root_schema_for::<CodexToolCallReplyParam>();

    #[expect(clippy::expect_used)]
    let schema_value =
        serde_json::to_value(&schema).expect("Codex reply tool schema should serialise to JSON");

    let tool_input_schema =
        serde_json::from_value::<ToolInputSchema>(schema_value).unwrap_or_else(|e| {
            panic!("failed to create Tool from schema: {e}");
        });

    Tool {
        name: "agcodex-reply".to_string(),
        title: Some("Codex Reply".to_string()),
        input_schema: tool_input_schema,
        output_schema: None,
        description: Some(
            "Continue a Codex session by providing the session id and prompt.".to_string(),
        ),
        annotations: None,
    }
}

#[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": "agcodex",
          "title": "Codex",
          "description": "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.",
          "inputSchema": {
            "type": "object",
            "properties": {
              "approval-policy": {
                "description": "Approval policy for shell commands generated by the model:\n`untrusted`, `on-failure`, `on-request`, `never`.",
                "enum": [
                  "untrusted",
                  "on-failure",
                  "on-request",
                  "never",
                  null
                ],
                "type": [
                  "string",
                  "null"
                ]
              },
              "sandbox": {
                "description": "Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`.",
                "enum": [
                  "read-only",
                  "workspace-write",
                  "danger-full-access",
                  null
                ],
                "type": [
                  "string",
                  "null"
                ]
              },
              "config": {
                "additionalProperties": true,
                "description": "Individual config settings that will override what is in\nCODEX_HOME/config.toml.",
                "type": [
                  "object",
                  "null"
                ]
              },
              "cwd": {
                "description": "Working directory for the session. If relative, it is resolved against\nthe server process's current working directory.",
                "type": [
                  "string",
                  "null"
                ]
              },
              "include-plan-tool": {
                "description": "Whether to include the plan tool in the conversation.",
                "type": [
                  "boolean",
                  "null"
                ]
              },
              "model": {
                "description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\").",
                "type": [
                  "string",
                  "null"
                ]
              },
              "profile": {
                "description": "Configuration profile from config.toml to specify default options.",
                "type": [
                  "string",
                  "null"
                ]
              },
              "prompt": {
                "description": "The *initial user prompt* to start the Codex conversation.",
                "type": "string"
              },
              "base-instructions": {
                "description": "The set of instructions to use instead of the default ones.",
                "type": [
                  "string",
                  "null"
                ]
              },
            },
            "required": [
              "prompt"
            ]
          }
        });
        assert_eq!(expected_tool_json, tool_json);
    }

    #[test]
    fn verify_codex_tool_reply_json_schema() {
        let tool = create_tool_for_codex_tool_call_reply_param();
        let tool_json = serde_json::to_value(&tool).expect("tool serializes");
        let expected_tool_json = serde_json::json!({
          "description": "Continue a Codex session by providing the session id and prompt.",
          "inputSchema": {
            "properties": {
              "prompt": {
                "description": "The *next user prompt* to continue the Codex conversation.",
                "type": "string"
              },
              "sessionId": {
                "description": "The *session id* for this conversation.",
                "type": "string"
              },
            },
            "required": [
              "sessionId",
              "prompt"
            ],
            "type": "object",
          },
          "name": "agcodex-reply",
          "title": "Codex Reply",
        });
        assert_eq!(expected_tool_json, tool_json);
    }
}