agcodex_mcp_server/
codex_tool_config.rs

1//! Configuration object accepted by the `codex` MCP tool-call.
2
3use agcodex_core::protocol::AskForApproval;
4use agcodex_mcp_types::Tool;
5use agcodex_mcp_types::ToolInputSchema;
6use agcodex_protocol::config_types::SandboxMode;
7use schemars::JsonSchema;
8use schemars::generate::SchemaSettings;
9use serde::Deserialize;
10use serde::Serialize;
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14use crate::json_to_toml::json_to_toml;
15
16/// Client-supplied configuration for a `codex` tool-call.
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
18#[serde(rename_all = "kebab-case")]
19pub struct CodexToolCallParam {
20    /// The *initial user prompt* to start the Codex conversation.
21    pub prompt: String,
22
23    /// Optional override for the model name (e.g. "o3", "o4-mini").
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub model: Option<String>,
26
27    /// Configuration profile from config.toml to specify default options.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub profile: Option<String>,
30
31    /// Working directory for the session. If relative, it is resolved against
32    /// the server process's current working directory.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub cwd: Option<String>,
35
36    /// Approval policy for shell commands generated by the model:
37    /// `untrusted`, `on-failure`, `on-request`, `never`.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub approval_policy: Option<CodexToolCallApprovalPolicy>,
40
41    /// Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub sandbox: Option<CodexToolCallSandboxMode>,
44
45    /// Individual config settings that will override what is in
46    /// CODEX_HOME/config.toml.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub config: Option<HashMap<String, serde_json::Value>>,
49
50    /// The set of instructions to use instead of the default ones.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub base_instructions: Option<String>,
53
54    /// Whether to include the plan tool in the conversation.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub include_plan_tool: Option<bool>,
57}
58
59/// Custom enum mirroring [`AskForApproval`], but has an extra dependency on
60/// [`JsonSchema`].
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
62#[serde(rename_all = "kebab-case")]
63pub enum CodexToolCallApprovalPolicy {
64    Untrusted,
65    OnFailure,
66    OnRequest,
67    Never,
68}
69
70impl From<CodexToolCallApprovalPolicy> for AskForApproval {
71    fn from(value: CodexToolCallApprovalPolicy) -> Self {
72        match value {
73            CodexToolCallApprovalPolicy::Untrusted => AskForApproval::UnlessTrusted,
74            CodexToolCallApprovalPolicy::OnFailure => AskForApproval::OnFailure,
75            CodexToolCallApprovalPolicy::OnRequest => AskForApproval::OnRequest,
76            CodexToolCallApprovalPolicy::Never => AskForApproval::Never,
77        }
78    }
79}
80
81/// Custom enum mirroring [`SandboxMode`] from config_types.rs, but with
82/// `JsonSchema` support.
83#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
84#[serde(rename_all = "kebab-case")]
85pub enum CodexToolCallSandboxMode {
86    ReadOnly,
87    WorkspaceWrite,
88    DangerFullAccess,
89}
90
91impl From<CodexToolCallSandboxMode> for SandboxMode {
92    fn from(value: CodexToolCallSandboxMode) -> Self {
93        match value {
94            CodexToolCallSandboxMode::ReadOnly => SandboxMode::ReadOnly,
95            CodexToolCallSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite,
96            CodexToolCallSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess,
97        }
98    }
99}
100
101/// Builds a `Tool` definition (JSON schema etc.) for the Codex tool-call.
102pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool {
103    let schema = SchemaSettings::draft2019_09()
104        .with(|s| {
105            s.inline_subschemas = true;
106        })
107        .into_generator()
108        .into_root_schema_for::<CodexToolCallParam>();
109
110    #[expect(clippy::expect_used)]
111    let schema_value =
112        serde_json::to_value(&schema).expect("Codex tool schema should serialise to JSON");
113
114    let tool_input_schema =
115        serde_json::from_value::<ToolInputSchema>(schema_value).unwrap_or_else(|e| {
116            panic!("failed to create Tool from schema: {e}");
117        });
118
119    Tool {
120        name: "agcodex".to_string(),
121        title: Some("Codex".to_string()),
122        input_schema: tool_input_schema,
123        // TODO(mbolin): This should be defined.
124        output_schema: None,
125        description: Some(
126            "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.".to_string(),
127        ),
128        annotations: None,
129    }
130}
131
132impl CodexToolCallParam {
133    /// Returns the initial user prompt to start the Codex conversation and the
134    /// effective Config object generated from the supplied parameters.
135    pub fn into_config(
136        self,
137        codex_linux_sandbox_exe: Option<PathBuf>,
138    ) -> std::io::Result<(String, agcodex_core::config::Config)> {
139        let Self {
140            prompt,
141            model,
142            profile,
143            cwd,
144            approval_policy,
145            sandbox,
146            config: cli_overrides,
147            base_instructions,
148            include_plan_tool,
149        } = self;
150
151        // Build the `ConfigOverrides` recognized by codex-core.
152        let overrides = agcodex_core::config::ConfigOverrides {
153            model,
154            config_profile: profile,
155            cwd: cwd.map(PathBuf::from),
156            approval_policy: approval_policy.map(Into::into),
157            sandbox_mode: sandbox.map(Into::into),
158            model_provider: None,
159            codex_linux_sandbox_exe,
160            base_instructions,
161            include_plan_tool,
162            include_apply_patch_tool: None,
163            disable_response_storage: None,
164            show_raw_agent_reasoning: None,
165        };
166
167        let cli_overrides = cli_overrides
168            .unwrap_or_default()
169            .into_iter()
170            .map(|(k, v)| (k, json_to_toml(v)))
171            .collect();
172
173        let cfg = agcodex_core::config::Config::load_with_cli_overrides(cli_overrides, overrides)?;
174
175        Ok((prompt, cfg))
176    }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
180#[serde(rename_all = "camelCase")]
181pub struct CodexToolCallReplyParam {
182    /// The *session id* for this conversation.
183    pub session_id: String,
184
185    /// The *next user prompt* to continue the Codex conversation.
186    pub prompt: String,
187}
188
189/// Builds a `Tool` definition for the `codex-reply` tool-call.
190pub(crate) fn create_tool_for_codex_tool_call_reply_param() -> Tool {
191    let schema = SchemaSettings::draft2019_09()
192        .with(|s| {
193            s.inline_subschemas = true;
194        })
195        .into_generator()
196        .into_root_schema_for::<CodexToolCallReplyParam>();
197
198    #[expect(clippy::expect_used)]
199    let schema_value =
200        serde_json::to_value(&schema).expect("Codex reply tool schema should serialise to JSON");
201
202    let tool_input_schema =
203        serde_json::from_value::<ToolInputSchema>(schema_value).unwrap_or_else(|e| {
204            panic!("failed to create Tool from schema: {e}");
205        });
206
207    Tool {
208        name: "agcodex-reply".to_string(),
209        title: Some("Codex Reply".to_string()),
210        input_schema: tool_input_schema,
211        output_schema: None,
212        description: Some(
213            "Continue a Codex session by providing the session id and prompt.".to_string(),
214        ),
215        annotations: None,
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use pretty_assertions::assert_eq;
223
224    /// We include a test to verify the exact JSON schema as "executable
225    /// documentation" for the schema. When can track changes to this test as a
226    /// way to audit changes to the generated schema.
227    ///
228    /// Seeing the fully expanded schema makes it easier to casually verify that
229    /// the generated JSON for enum types such as "approval-policy" is compact.
230    /// Ideally, modelcontextprotocol/inspector would provide a simpler UI for
231    /// enum fields versus open string fields to take advantage of this.
232    ///
233    /// As of 2025-05-04, there is an open PR for this:
234    /// https://github.com/modelcontextprotocol/inspector/pull/196
235    #[test]
236    fn verify_codex_tool_json_schema() {
237        let tool = create_tool_for_codex_tool_call_param();
238        let tool_json = serde_json::to_value(&tool).expect("tool serializes");
239        let expected_tool_json = serde_json::json!({
240          "name": "agcodex",
241          "title": "Codex",
242          "description": "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.",
243          "inputSchema": {
244            "type": "object",
245            "properties": {
246              "approval-policy": {
247                "description": "Approval policy for shell commands generated by the model:\n`untrusted`, `on-failure`, `on-request`, `never`.",
248                "enum": [
249                  "untrusted",
250                  "on-failure",
251                  "on-request",
252                  "never",
253                  null
254                ],
255                "type": [
256                  "string",
257                  "null"
258                ]
259              },
260              "sandbox": {
261                "description": "Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`.",
262                "enum": [
263                  "read-only",
264                  "workspace-write",
265                  "danger-full-access",
266                  null
267                ],
268                "type": [
269                  "string",
270                  "null"
271                ]
272              },
273              "config": {
274                "additionalProperties": true,
275                "description": "Individual config settings that will override what is in\nCODEX_HOME/config.toml.",
276                "type": [
277                  "object",
278                  "null"
279                ]
280              },
281              "cwd": {
282                "description": "Working directory for the session. If relative, it is resolved against\nthe server process's current working directory.",
283                "type": [
284                  "string",
285                  "null"
286                ]
287              },
288              "include-plan-tool": {
289                "description": "Whether to include the plan tool in the conversation.",
290                "type": [
291                  "boolean",
292                  "null"
293                ]
294              },
295              "model": {
296                "description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\").",
297                "type": [
298                  "string",
299                  "null"
300                ]
301              },
302              "profile": {
303                "description": "Configuration profile from config.toml to specify default options.",
304                "type": [
305                  "string",
306                  "null"
307                ]
308              },
309              "prompt": {
310                "description": "The *initial user prompt* to start the Codex conversation.",
311                "type": "string"
312              },
313              "base-instructions": {
314                "description": "The set of instructions to use instead of the default ones.",
315                "type": [
316                  "string",
317                  "null"
318                ]
319              },
320            },
321            "required": [
322              "prompt"
323            ]
324          }
325        });
326        assert_eq!(expected_tool_json, tool_json);
327    }
328
329    #[test]
330    fn verify_codex_tool_reply_json_schema() {
331        let tool = create_tool_for_codex_tool_call_reply_param();
332        let tool_json = serde_json::to_value(&tool).expect("tool serializes");
333        let expected_tool_json = serde_json::json!({
334          "description": "Continue a Codex session by providing the session id and prompt.",
335          "inputSchema": {
336            "properties": {
337              "prompt": {
338                "description": "The *next user prompt* to continue the Codex conversation.",
339                "type": "string"
340              },
341              "sessionId": {
342                "description": "The *session id* for this conversation.",
343                "type": "string"
344              },
345            },
346            "required": [
347              "sessionId",
348              "prompt"
349            ],
350            "type": "object",
351          },
352          "name": "agcodex-reply",
353          "title": "Codex Reply",
354        });
355        assert_eq!(expected_tool_json, tool_json);
356    }
357}