feat: make Codex available as a tool when running it as an MCP server (#811)
This PR replaces the placeholder `"echo"` tool call in the MCP server with a `"codex"` tool that calls Codex. Events such as `ExecApprovalRequest` and `ApplyPatchApprovalRequest` are not handled properly yet, but I have `approval_policy = "never"` set in my `~/.codex/config.toml` such that those codepaths are not exercised. The schema for this MPC tool is defined by a new `CodexToolCallParam` struct introduced in this PR. It is fairly similar to `ConfigOverrides`, as the param is used to help create the `Config` used to start the Codex session, though it also includes the `prompt` used to kick off the session. This PR also introduces the use of the third-party `schemars` crate to generate the JSON schema, which is verified in the `verify_codex_tool_json_schema()` unit test. Events that are dispatched during the Codex session are sent back to the MCP client as MCP notifications. This gives the client a way to monitor progress as the tool call itself may take minutes to complete depending on the complexity of the task requested by the user. In the video below, I launched the server via: ```shell mcp-server$ RUST_LOG=debug npx @modelcontextprotocol/inspector cargo run -- ``` In the video, you can see the flow of: * requesting the list of tools * choosing the **codex** tool * entering a value for **prompt** and then making the tool call Note that I left the other fields blank because when unspecified, the values in my `~/.codex/config.toml` were used: https://github.com/user-attachments/assets/1975058c-b004-43ef-8c8d-800a953b8192 Note that while using the inspector, I did run into https://github.com/modelcontextprotocol/inspector/issues/293, though the tip about ensuring I had only one instance of the **MCP Inspector** tab open in my browser seemed to fix things.
This commit is contained in:
244
codex-rs/mcp-server/src/codex_tool_config.rs
Normal file
244
codex-rs/mcp-server/src/codex_tool_config.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user