Skip to content

feat: make Codex available as a tool when running it as an MCP server #811

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 4 additions & 11 deletions codex-rs/mcp-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -28,3 +18,6 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }

[dev-dependencies]
pretty_assertions = "1.4.1"
244 changes: 244 additions & 0 deletions codex-rs/mcp-server/src/codex_tool_config.rs
Original file line number Diff line number Diff line change
@@ -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<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>,

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

/// 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<Vec<CodexToolCallSandboxPermission>>,

/// Disable server-side response storage.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disable_response_storage: Option<bool>,
// Custom system instructions.
// #[serde(default, skip_serializing_if = "Option::is_none")]
// pub instructions: Option<String>,
}

// 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<CodexToolCallApprovalPolicy> 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<CodexToolCallSandboxPermission> 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::<CodexToolCallParam>();
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: "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::<Vec<_>>())
});

// 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);
}
}
Loading