From 6a8e743d578c19c97082c8c47bd1f529d29ed737 Mon Sep 17 00:00:00 2001 From: easong-openai Date: Sun, 14 Sep 2025 21:30:56 -0700 Subject: [PATCH] initial mcp add interface (#3543) Adds `codex mcp add`, `codex mcp list`, `codex mcp remove`. Currently writes to global config. --- codex-rs/Cargo.lock | 4 + codex-rs/cli/Cargo.toml | 6 + codex-rs/cli/src/main.rs | 14 +- codex-rs/cli/src/mcp_cmd.rs | 370 +++++++++++++++++++++++++++ codex-rs/cli/tests/mcp_add_remove.rs | 86 +++++++ codex-rs/cli/tests/mcp_list.rs | 106 ++++++++ codex-rs/core/src/config.rs | 127 +++++++++ codex-rs/docs/codex_mcp_interface.md | 123 +++++++++ docs/config.md | 18 ++ 9 files changed, 849 insertions(+), 5 deletions(-) create mode 100644 codex-rs/cli/src/mcp_cmd.rs create mode 100644 codex-rs/cli/tests/mcp_add_remove.rs create mode 100644 codex-rs/cli/tests/mcp_list.rs create mode 100644 codex-rs/docs/codex_mcp_interface.md diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1e8b7a34..5993a833 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -625,6 +625,7 @@ name = "codex-cli" version = "0.0.0" dependencies = [ "anyhow", + "assert_cmd", "clap", "clap_complete", "codex-arg0", @@ -637,7 +638,10 @@ dependencies = [ "codex-protocol", "codex-protocol-ts", "codex-tui", + "predicates", + "pretty_assertions", "serde_json", + "tempfile", "tokio", "tracing", "tracing-subscriber", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index f7af3349..571844c4 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -38,3 +38,9 @@ tokio = { version = "1", features = [ tracing = "0.1.41" tracing-subscriber = "0.3.19" codex-protocol-ts = { path = "../protocol-ts" } + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +pretty_assertions = "1" +tempfile = "3" diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1f13405f..7e4e6fcc 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -17,6 +17,9 @@ use codex_exec::Cli as ExecCli; use codex_tui::Cli as TuiCli; use std::path::PathBuf; +mod mcp_cmd; + +use crate::mcp_cmd::McpCli; use crate::proto::ProtoCli; /// Codex CLI @@ -56,8 +59,8 @@ enum Subcommand { /// Remove stored authentication credentials. Logout(LogoutCommand), - /// Experimental: run Codex as an MCP server. - Mcp, + /// [experimental] Run Codex as an MCP server and manage MCP servers. + Mcp(McpCli), /// Run the Protocol stream via stdin/stdout #[clap(visible_alias = "p")] @@ -182,9 +185,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ); codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?; } - Some(Subcommand::Mcp) => { - codex_mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides.clone()) - .await?; + Some(Subcommand::Mcp(mut mcp_cli)) => { + // Propagate any root-level config overrides (e.g. `-c key=value`). + prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); + mcp_cli.run(codex_linux_sandbox_exe).await?; } Some(Subcommand::Resume(ResumeCommand { session_id, last })) => { // Start with the parsed interactive CLI so resume shares the same diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs new file mode 100644 index 00000000..437511ad --- /dev/null +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -0,0 +1,370 @@ +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use codex_common::CliConfigOverrides; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::config::find_codex_home; +use codex_core::config::load_global_mcp_servers; +use codex_core::config::write_global_mcp_servers; +use codex_core::config_types::McpServerConfig; + +/// [experimental] Launch Codex as an MCP server or manage configured MCP servers. +/// +/// Subcommands: +/// - `serve` — run the MCP server on stdio +/// - `list` — list configured servers (with `--json`) +/// - `get` — show a single server (with `--json`) +/// - `add` — add a server launcher entry to `~/.codex/config.toml` +/// - `remove` — delete a server entry +#[derive(Debug, clap::Parser)] +pub struct McpCli { + #[clap(flatten)] + pub config_overrides: CliConfigOverrides, + + #[command(subcommand)] + pub cmd: Option, +} + +#[derive(Debug, clap::Subcommand)] +pub enum McpSubcommand { + /// [experimental] Run the Codex MCP server (stdio transport). + Serve, + + /// [experimental] List configured MCP servers. + List(ListArgs), + + /// [experimental] Show details for a configured MCP server. + Get(GetArgs), + + /// [experimental] Add a global MCP server entry. + Add(AddArgs), + + /// [experimental] Remove a global MCP server entry. + Remove(RemoveArgs), +} + +#[derive(Debug, clap::Parser)] +pub struct ListArgs { + /// Output the configured servers as JSON. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, clap::Parser)] +pub struct GetArgs { + /// Name of the MCP server to display. + pub name: String, + + /// Output the server configuration as JSON. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, clap::Parser)] +pub struct AddArgs { + /// Name for the MCP server configuration. + pub name: String, + + /// Environment variables to set when launching the server. + #[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")] + pub env: Vec<(String, String)>, + + /// Command to launch the MCP server. + #[arg(trailing_var_arg = true, num_args = 1..)] + pub command: Vec, +} + +#[derive(Debug, clap::Parser)] +pub struct RemoveArgs { + /// Name of the MCP server configuration to remove. + pub name: String, +} + +impl McpCli { + pub async fn run(self, codex_linux_sandbox_exe: Option) -> Result<()> { + let McpCli { + config_overrides, + cmd, + } = self; + let subcommand = cmd.unwrap_or(McpSubcommand::Serve); + + match subcommand { + McpSubcommand::Serve => { + codex_mcp_server::run_main(codex_linux_sandbox_exe, config_overrides).await?; + } + McpSubcommand::List(args) => { + run_list(&config_overrides, args)?; + } + McpSubcommand::Get(args) => { + run_get(&config_overrides, args)?; + } + McpSubcommand::Add(args) => { + run_add(&config_overrides, args)?; + } + McpSubcommand::Remove(args) => { + run_remove(&config_overrides, args)?; + } + } + + Ok(()) + } +} + +fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> { + // Validate any provided overrides even though they are not currently applied. + config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + + let AddArgs { name, env, command } = add_args; + + validate_server_name(&name)?; + + let mut command_parts = command.into_iter(); + let command_bin = command_parts + .next() + .ok_or_else(|| anyhow!("command is required"))?; + let command_args: Vec = command_parts.collect(); + + let env_map = if env.is_empty() { + None + } else { + let mut map = HashMap::new(); + for (key, value) in env { + map.insert(key, value); + } + Some(map) + }; + + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let mut servers = load_global_mcp_servers(&codex_home) + .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; + + let new_entry = McpServerConfig { + command: command_bin, + args: command_args, + env: env_map, + startup_timeout_ms: None, + }; + + servers.insert(name.clone(), new_entry); + + write_global_mcp_servers(&codex_home, &servers) + .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?; + + println!("Added global MCP server '{name}'."); + + Ok(()) +} + +fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> { + config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + + let RemoveArgs { name } = remove_args; + + validate_server_name(&name)?; + + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let mut servers = load_global_mcp_servers(&codex_home) + .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; + + let removed = servers.remove(&name).is_some(); + + if removed { + write_global_mcp_servers(&codex_home, &servers) + .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?; + } + + if removed { + println!("Removed global MCP server '{name}'."); + } else { + println!("No MCP server named '{name}' found."); + } + + Ok(()) +} + +fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> { + let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) + .context("failed to load configuration")?; + + let mut entries: Vec<_> = config.mcp_servers.iter().collect(); + entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + + if list_args.json { + let json_entries: Vec<_> = entries + .into_iter() + .map(|(name, cfg)| { + let env = cfg.env.as_ref().map(|env| { + env.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }); + serde_json::json!({ + "name": name, + "command": cfg.command, + "args": cfg.args, + "env": env, + "startup_timeout_ms": cfg.startup_timeout_ms, + }) + }) + .collect(); + let output = serde_json::to_string_pretty(&json_entries)?; + println!("{output}"); + return Ok(()); + } + + if entries.is_empty() { + println!("No MCP servers configured yet. Try `codex mcp add my-tool -- my-command`."); + return Ok(()); + } + + let mut rows: Vec<[String; 4]> = Vec::new(); + for (name, cfg) in entries { + let args = if cfg.args.is_empty() { + "-".to_string() + } else { + cfg.args.join(" ") + }; + + let env = match cfg.env.as_ref() { + None => "-".to_string(), + Some(map) if map.is_empty() => "-".to_string(), + Some(map) => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + } + }; + + rows.push([name.clone(), cfg.command.clone(), args, env]); + } + + let mut widths = ["Name".len(), "Command".len(), "Args".len(), "Env".len()]; + for row in &rows { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.len()); + } + } + + println!( + "{: Result<()> { + let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) + .context("failed to load configuration")?; + + let Some(server) = config.mcp_servers.get(&get_args.name) else { + bail!("No MCP server named '{name}' found.", name = get_args.name); + }; + + if get_args.json { + let env = server.env.as_ref().map(|env| { + env.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }); + let output = serde_json::to_string_pretty(&serde_json::json!({ + "name": get_args.name, + "command": server.command, + "args": server.args, + "env": env, + "startup_timeout_ms": server.startup_timeout_ms, + }))?; + println!("{output}"); + return Ok(()); + } + + println!("{}", get_args.name); + println!(" command: {}", server.command); + let args = if server.args.is_empty() { + "-".to_string() + } else { + server.args.join(" ") + }; + println!(" args: {args}"); + let env_display = match server.env.as_ref() { + None => "-".to_string(), + Some(map) if map.is_empty() => "-".to_string(), + Some(map) => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + } + }; + println!(" env: {env_display}"); + if let Some(timeout) = server.startup_timeout_ms { + println!(" startup_timeout_ms: {timeout}"); + } + println!(" remove: codex mcp remove {}", get_args.name); + + Ok(()) +} + +fn parse_env_pair(raw: &str) -> Result<(String, String), String> { + let mut parts = raw.splitn(2, '='); + let key = parts + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "environment entries must be in KEY=VALUE form".to_string())?; + let value = parts + .next() + .map(str::to_string) + .ok_or_else(|| "environment entries must be in KEY=VALUE form".to_string())?; + + Ok((key.to_string(), value)) +} + +fn validate_server_name(name: &str) -> Result<()> { + let is_valid = !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + + if is_valid { + Ok(()) + } else { + bail!("invalid server name '{name}' (use letters, numbers, '-', '_')"); + } +} diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs new file mode 100644 index 00000000..9e54f0d8 --- /dev/null +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -0,0 +1,86 @@ +use std::path::Path; + +use anyhow::Result; +use codex_core::config::load_global_mcp_servers; +use predicates::str::contains; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::cargo_bin("codex")?; + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[test] +fn add_and_remove_server_updates_global_config() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args(["mcp", "add", "docs", "--", "echo", "hello"]) + .assert() + .success() + .stdout(contains("Added global MCP server 'docs'.")); + + let servers = load_global_mcp_servers(codex_home.path())?; + assert_eq!(servers.len(), 1); + let docs = servers.get("docs").expect("server should exist"); + assert_eq!(docs.command, "echo"); + assert_eq!(docs.args, vec!["hello".to_string()]); + assert!(docs.env.is_none()); + + let mut remove_cmd = codex_command(codex_home.path())?; + remove_cmd + .args(["mcp", "remove", "docs"]) + .assert() + .success() + .stdout(contains("Removed global MCP server 'docs'.")); + + let servers = load_global_mcp_servers(codex_home.path())?; + assert!(servers.is_empty()); + + let mut remove_again_cmd = codex_command(codex_home.path())?; + remove_again_cmd + .args(["mcp", "remove", "docs"]) + .assert() + .success() + .stdout(contains("No MCP server named 'docs' found.")); + + let servers = load_global_mcp_servers(codex_home.path())?; + assert!(servers.is_empty()); + + Ok(()) +} + +#[test] +fn add_with_env_preserves_key_order_and_values() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args([ + "mcp", + "add", + "envy", + "--env", + "FOO=bar", + "--env", + "ALPHA=beta", + "--", + "python", + "server.py", + ]) + .assert() + .success(); + + let servers = load_global_mcp_servers(codex_home.path())?; + let envy = servers.get("envy").expect("server should exist"); + let env = envy.env.as_ref().expect("env should be present"); + + assert_eq!(env.len(), 2); + assert_eq!(env.get("FOO"), Some(&"bar".to_string())); + assert_eq!(env.get("ALPHA"), Some(&"beta".to_string())); + + Ok(()) +} diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs new file mode 100644 index 00000000..e53f42cc --- /dev/null +++ b/codex-rs/cli/tests/mcp_list.rs @@ -0,0 +1,106 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::cargo_bin("codex")?; + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[test] +fn list_shows_empty_state() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + let output = cmd.args(["mcp", "list"]).output()?; + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + assert!(stdout.contains("No MCP servers configured yet.")); + + Ok(()) +} + +#[test] +fn list_and_get_render_expected_output() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add = codex_command(codex_home.path())?; + add.args([ + "mcp", + "add", + "docs", + "--env", + "TOKEN=secret", + "--", + "docs-server", + "--port", + "4000", + ]) + .assert() + .success(); + + let mut list_cmd = codex_command(codex_home.path())?; + let list_output = list_cmd.args(["mcp", "list"]).output()?; + assert!(list_output.status.success()); + let stdout = String::from_utf8(list_output.stdout)?; + assert!(stdout.contains("Name")); + assert!(stdout.contains("docs")); + assert!(stdout.contains("docs-server")); + assert!(stdout.contains("TOKEN=secret")); + + let mut list_json_cmd = codex_command(codex_home.path())?; + let json_output = list_json_cmd.args(["mcp", "list", "--json"]).output()?; + assert!(json_output.status.success()); + let stdout = String::from_utf8(json_output.stdout)?; + let parsed: JsonValue = serde_json::from_str(&stdout)?; + let array = parsed.as_array().expect("expected array"); + assert_eq!(array.len(), 1); + let entry = &array[0]; + assert_eq!(entry.get("name"), Some(&JsonValue::String("docs".into()))); + assert_eq!( + entry.get("command"), + Some(&JsonValue::String("docs-server".into())) + ); + + let args = entry + .get("args") + .and_then(|v| v.as_array()) + .expect("args array"); + assert_eq!( + args, + &vec![ + JsonValue::String("--port".into()), + JsonValue::String("4000".into()) + ] + ); + + let env = entry + .get("env") + .and_then(|v| v.as_object()) + .expect("env map"); + assert_eq!(env.get("TOKEN"), Some(&JsonValue::String("secret".into()))); + + let mut get_cmd = codex_command(codex_home.path())?; + let get_output = get_cmd.args(["mcp", "get", "docs"]).output()?; + assert!(get_output.status.success()); + let stdout = String::from_utf8(get_output.stdout)?; + assert!(stdout.contains("docs")); + assert!(stdout.contains("command: docs-server")); + assert!(stdout.contains("args: --port 4000")); + assert!(stdout.contains("env: TOKEN=secret")); + assert!(stdout.contains("remove: codex mcp remove docs")); + + let mut get_json_cmd = codex_command(codex_home.path())?; + get_json_cmd + .args(["mcp", "get", "docs", "--json"]) + .assert() + .success() + .stdout(contains("\"name\": \"docs\"")); + + Ok(()) +} diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 1b54f8b9..0c0d181d 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -25,12 +25,16 @@ use codex_protocol::mcp_protocol::Tools; use codex_protocol::mcp_protocol::UserSavedConfig; use dirs::home_dir; use serde::Deserialize; +use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; use tempfile::NamedTempFile; use toml::Value as TomlValue; +use toml_edit::Array as TomlArray; use toml_edit::DocumentMut; +use toml_edit::Item as TomlItem; +use toml_edit::Table as TomlTable; const OPENAI_DEFAULT_MODEL: &str = "gpt-5"; const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5"; @@ -265,6 +269,88 @@ pub fn load_config_as_toml(codex_home: &Path) -> std::io::Result { } } +pub fn load_global_mcp_servers( + codex_home: &Path, +) -> std::io::Result> { + let root_value = load_config_as_toml(codex_home)?; + let Some(servers_value) = root_value.get("mcp_servers") else { + return Ok(BTreeMap::new()); + }; + + servers_value + .clone() + .try_into() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) +} + +pub fn write_global_mcp_servers( + codex_home: &Path, + servers: &BTreeMap, +) -> std::io::Result<()> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let mut doc = match std::fs::read_to_string(&config_path) { + Ok(contents) => contents + .parse::() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(), + Err(e) => return Err(e), + }; + + doc.as_table_mut().remove("mcp_servers"); + + if !servers.is_empty() { + let mut table = TomlTable::new(); + table.set_implicit(true); + doc["mcp_servers"] = TomlItem::Table(table); + + for (name, config) in servers { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + entry["command"] = toml_edit::value(config.command.clone()); + + if !config.args.is_empty() { + let mut args = TomlArray::new(); + for arg in &config.args { + args.push(arg.clone()); + } + entry["args"] = TomlItem::Value(args.into()); + } + + if let Some(env) = &config.env + && !env.is_empty() + { + let mut env_table = TomlTable::new(); + env_table.set_implicit(false); + let mut pairs: Vec<_> = env.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in pairs { + env_table.insert(key, toml_edit::value(value.clone())); + } + entry["env"] = TomlItem::Table(env_table); + } + + if let Some(timeout) = config.startup_timeout_ms { + let timeout = i64::try_from(timeout).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "startup_timeout_ms exceeds supported range", + ) + })?; + entry["startup_timeout_ms"] = toml_edit::value(timeout); + } + + doc["mcp_servers"][name.as_str()] = TomlItem::Table(entry); + } + } + + std::fs::create_dir_all(codex_home)?; + let tmp_file = NamedTempFile::new_in(codex_home)?; + std::fs::write(tmp_file.path(), doc.to_string())?; + tmp_file.persist(config_path).map_err(|err| err.error)?; + + Ok(()) +} + fn set_project_trusted_inner(doc: &mut DocumentMut, project_path: &Path) -> anyhow::Result<()> { // Ensure we render a human-friendly structure: // @@ -1162,6 +1248,47 @@ exclude_slash_tmp = true ); } + #[test] + fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = load_global_mcp_servers(codex_home.path())?; + assert!(servers.is_empty()); + + Ok(()) + } + + #[test] + fn write_global_mcp_servers_round_trips_entries() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let mut servers = BTreeMap::new(); + servers.insert( + "docs".to_string(), + McpServerConfig { + command: "echo".to_string(), + args: vec!["hello".to_string()], + env: None, + startup_timeout_ms: None, + }, + ); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let loaded = load_global_mcp_servers(codex_home.path())?; + assert_eq!(loaded.len(), 1); + let docs = loaded.get("docs").expect("docs entry"); + assert_eq!(docs.command, "echo"); + assert_eq!(docs.args, vec!["hello".to_string()]); + + let empty = BTreeMap::new(); + write_global_mcp_servers(codex_home.path(), &empty)?; + let loaded = load_global_mcp_servers(codex_home.path())?; + assert!(loaded.is_empty()); + + Ok(()) + } + #[tokio::test] async fn persist_model_selection_updates_defaults() -> anyhow::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md new file mode 100644 index 00000000..1b048085 --- /dev/null +++ b/codex-rs/docs/codex_mcp_interface.md @@ -0,0 +1,123 @@ +# Codex MCP Interface [experimental] + +This document describes Codex’s experimental MCP interface: a JSON‑RPC API that runs over the Model Context Protocol (MCP) transport to control a local Codex engine. + +- Status: experimental and subject to change without notice +- Server binary: `codex mcp` (or `codex-mcp-server`) +- Transport: standard MCP over stdio (JSON‑RPC 2.0, line‑delimited) + +## Overview + +Codex exposes a small set of MCP‑compatible methods to create and manage conversations, send user input, receive live events, and handle approval prompts. The types are defined in `protocol/src/mcp_protocol.rs` and re‑used by the MCP server implementation in `mcp-server/`. + +At a glance: + +- Conversations + - `newConversation` → start a Codex session + - `sendUserMessage` / `sendUserTurn` → send user input into a conversation + - `interruptConversation` → stop the current turn + - `listConversations`, `resumeConversation`, `archiveConversation` +- Configuration and info + - `getUserSavedConfig`, `setDefaultModel`, `getUserAgent`, `userInfo` +- Auth + - `loginApiKey`, `loginChatGpt`, `cancelLoginChatGpt`, `logoutChatGpt`, `getAuthStatus` +- Utilities + - `gitDiffToRemote`, `execOneOffCommand` +- Approvals (server → client requests) + - `applyPatchApproval`, `execCommandApproval` +- Notifications (server → client) + - `loginChatGptComplete`, `authStatusChange` + - `codex/event` stream with agent events + +See code for full type definitions and exact shapes: `protocol/src/mcp_protocol.rs`. + +## Starting the server + +Run Codex as an MCP server and connect an MCP client: + +```bash +codex mcp | your_mcp_client +``` + +For a simple inspection UI, you can also try: + +```bash +npx @modelcontextprotocol/inspector codex mcp +``` + +## Conversations + +Start a new session with optional overrides: + +Request `newConversation` params (subset): + +- `model`: string model id (e.g. "o3", "gpt-5") +- `profile`: optional named profile +- `cwd`: optional working directory +- `approvalPolicy`: `untrusted` | `on-request` | `on-failure` | `never` +- `sandbox`: `read-only` | `workspace-write` | `danger-full-access` +- `config`: map of additional config overrides +- `baseInstructions`: optional instruction override +- `includePlanTool` / `includeApplyPatchTool`: booleans + +Response: `{ conversationId, model, reasoningEffort?, rolloutPath }` + +Send input to the active turn: + +- `sendUserMessage` → enqueue items to the conversation +- `sendUserTurn` → structured turn with explicit `cwd`, `approvalPolicy`, `sandboxPolicy`, `model`, optional `effort`, and `summary` + +Interrupt a running turn: `interruptConversation`. + +List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversation`. + +## Event stream + +While a conversation runs, the server sends notifications: + +- `codex/event` with the serialized Codex event payload. The shape matches `core/src/protocol.rs`’s `Event` and `EventMsg` types. Some notifications include a `_meta.requestId` to correlate with the originating request. +- Auth notifications via method names `loginChatGptComplete` and `authStatusChange`. + +Clients should render events and, when present, surface approval requests (see next section). + +## Approvals (server → client) + +When Codex needs approval to apply changes or run commands, the server issues JSON‑RPC requests to the client: + +- `applyPatchApproval { conversationId, callId, fileChanges, reason?, grantRoot? }` +- `execCommandApproval { conversationId, callId, command, cwd, reason? }` + +The client must reply with `{ decision: "allow" | "deny" }` for each request. + +## Auth helpers + +For ChatGPT or API‑key based auth flows, the server exposes helpers: + +- `loginApiKey { apiKey }` +- `loginChatGpt` → returns `{ loginId, authUrl }`; browser completes flow; then `loginChatGptComplete` notification follows +- `cancelLoginChatGpt { loginId }`, `logoutChatGpt`, `getAuthStatus { includeToken?, refreshToken? }` + +## Example: start and send a message + +```json +{ "jsonrpc": "2.0", "id": 1, "method": "newConversation", "params": { "model": "gpt-5", "approvalPolicy": "on-request" } } +``` + +Server responds: + +```json +{ "jsonrpc": "2.0", "id": 1, "result": { "conversationId": "c7b0…", "model": "gpt-5", "rolloutPath": "/path/to/rollout.jsonl" } } +``` + +Then send input: + +```json +{ "jsonrpc": "2.0", "id": 2, "method": "sendUserMessage", "params": { "conversationId": "c7b0…", "items": [{ "type": "text", "text": "Hello Codex" }] } } +``` + +While processing, the server emits `codex/event` notifications containing agent output, approvals, and status updates. + +## Compatibility and stability + +This interface is experimental. Method names, fields, and event shapes may evolve. For the authoritative schema, consult `protocol/src/mcp_protocol.rs` and the corresponding server wiring in `mcp-server/`. + diff --git a/docs/config.md b/docs/config.md index efd152aa..6f10cb3f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -367,6 +367,24 @@ env = { "API_KEY" = "value" } startup_timeout_ms = 20_000 ``` +You can also manage these entries from the CLI [experimental]: + +```shell +# Add a server (env can be repeated; `--` separates the launcher command) +codex mcp add docs -- docs-server --port 4000 + +# List configured servers (pretty table or JSON) +codex mcp list +codex mcp list --json + +# Show one server (table or JSON) +codex mcp get docs +codex mcp get docs --json + +# Remove a server +codex mcp remove docs +``` + ## shell_environment_policy Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it now passes **your full environment** to those subprocesses. You can tune this behavior via the **`shell_environment_policy`** block in `config.toml`: