Files
codex/codex-rs/mcp-server/src/codex_tool_config.rs
Michael Bolin 0c09dc3c03 feat: add threadId to MCP server messages (#9192)
This favors `threadId` instead of `conversationId` so we use the same
terms as https://developers.openai.com/codex/sdk/.

To test the local build:

```
cd codex-rs
cargo build --bin codex
npx -y @modelcontextprotocol/inspector ./target/debug/codex mcp-server
```

I sent:

```json
{
  "method": "tools/call",
  "params": {
    "name": "codex",
    "arguments": {
      "prompt": "favorite ls option?"
    },
    "_meta": {
      "progressToken": 0
    }
  }
}
```

and got:

```json
{
  "content": [
    {
      "type": "text",
      "text": "`ls -lah` (or `ls -alh`) — long listing, includes dotfiles, human-readable sizes."
    }
  ],
  "structuredContent": {
    "threadId": "019bbb20-bff6-7130-83aa-bf45ab33250e"
  }
}
```

and successfully used the `threadId` in the follow-up with the
`codex-reply` tool call:

```json
{
  "method": "tools/call",
  "params": {
    "name": "codex-reply",
    "arguments": {
      "prompt": "what is the long versoin",
      "threadId": "019bbb20-bff6-7130-83aa-bf45ab33250e"
    },
    "_meta": {
      "progressToken": 1
    }
  }
}
```

whose response also has the `threadId`:

```json
{
  "content": [
    {
      "type": "text",
      "text": "Long listing is `ls -l` (adds permissions, owner/group, size, timestamp)."
    }
  ],
  "structuredContent": {
    "threadId": "019bbb20-bff6-7130-83aa-bf45ab33250e"
  }
}
```

Fixes https://github.com/openai/codex/issues/3712.
2026-01-13 22:14:41 -08:00

370 lines
14 KiB
Rust

//! Configuration object accepted by the `codex` MCP tool-call.
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::protocol::AskForApproval;
use codex_protocol::ThreadId;
use codex_protocol::config_types::SandboxMode;
use codex_utils_json_to_toml::json_to_toml;
use mcp_types::Tool;
use mcp_types::ToolInputSchema;
use schemars::JsonSchema;
use schemars::r#gen::SchemaSettings;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
/// 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. 'gpt-5.2', 'gpt-5.2-codex').
#[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>,
/// Developer instructions that should be injected as a developer role message.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub developer_instructions: Option<String>,
/// Prompt used when compacting the conversation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compact_prompt: Option<String>,
}
/// 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;
s.option_add_null_type = false;
})
.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: "codex".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 async fn into_config(
self,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> std::io::Result<(String, Config)> {
let Self {
prompt,
model,
profile,
cwd,
approval_policy,
sandbox,
config: cli_overrides,
base_instructions,
developer_instructions,
compact_prompt,
} = self;
// Build the `ConfigOverrides` recognized by codex-core.
let overrides = ConfigOverrides {
model,
config_profile: profile,
cwd: cwd.map(PathBuf::from),
approval_policy: approval_policy.map(Into::into),
sandbox_mode: sandbox.map(Into::into),
codex_linux_sandbox_exe,
base_instructions,
developer_instructions,
compact_prompt,
..Default::default()
};
let cli_overrides = cli_overrides
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, json_to_toml(v)))
.collect();
let cfg =
Config::load_with_cli_overrides_and_harness_overrides(cli_overrides, overrides).await?;
Ok((prompt, cfg))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CodexToolCallReplyParam {
/// DEPRECATED: use threadId instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
conversation_id: Option<String>,
/// The thread id for this Codex session.
/// This field is required, but we keep it optional here for backward
/// compatibility for clients that still use conversationId.
#[serde(default, skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
/// The *next user prompt* to continue the Codex conversation.
pub prompt: String,
}
impl CodexToolCallReplyParam {
pub(crate) fn get_thread_id(&self) -> anyhow::Result<ThreadId> {
if let Some(thread_id) = &self.thread_id {
let thread_id = ThreadId::from_string(thread_id)?;
Ok(thread_id)
} else if let Some(conversation_id) = &self.conversation_id {
let thread_id = ThreadId::from_string(conversation_id)?;
Ok(thread_id)
} else {
Err(anyhow::anyhow!(
"either threadId or conversationId must be provided"
))
}
}
}
/// 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;
s.option_add_null_type = false;
})
.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: "codex-reply".to_string(),
title: Some("Codex Reply".to_string()),
input_schema: tool_input_schema,
output_schema: None,
description: Some(
"Continue a Codex conversation by providing the thread 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!({
"description": "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.",
"inputSchema": {
"properties": {
"approval-policy": {
"description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`.",
"enum": [
"untrusted",
"on-failure",
"on-request",
"never"
],
"type": "string"
},
"base-instructions": {
"description": "The set of instructions to use instead of the default ones.",
"type": "string"
},
"compact-prompt": {
"description": "Prompt used when compacting the conversation.",
"type": "string"
},
"config": {
"additionalProperties": true,
"description": "Individual config settings that will override what is in CODEX_HOME/config.toml.",
"type": "object"
},
"cwd": {
"description": "Working directory for the session. If relative, it is resolved against the server process's current working directory.",
"type": "string"
},
"developer-instructions": {
"description": "Developer instructions that should be injected as a developer role message.",
"type": "string"
},
"model": {
"description": "Optional override for the model name (e.g. 'gpt-5.2', 'gpt-5.2-codex').",
"type": "string"
},
"profile": {
"description": "Configuration profile from config.toml to specify default options.",
"type": "string"
},
"prompt": {
"description": "The *initial user prompt* to start the Codex conversation.",
"type": "string"
},
"sandbox": {
"description": "Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`.",
"enum": [
"read-only",
"workspace-write",
"danger-full-access"
],
"type": "string"
}
},
"required": [
"prompt"
],
"type": "object"
},
"name": "codex",
"title": "Codex"
});
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 conversation by providing the thread id and prompt.",
"inputSchema": {
"properties": {
"conversationId": {
"description": "DEPRECATED: use threadId instead.",
"type": "string"
},
"prompt": {
"description": "The *next user prompt* to continue the Codex conversation.",
"type": "string"
},
"threadId": {
"description": "The thread id for this Codex session. This field is required, but we keep it optional here for backward compatibility for clients that still use conversationId.",
"type": "string"
}
},
"required": [
"prompt",
],
"type": "object",
},
"name": "codex-reply",
"title": "Codex Reply",
});
assert_eq!(expected_tool_json, tool_json);
}
}