initial mcp add interface (#3543)
Adds `codex mcp add`, `codex mcp list`, `codex mcp remove`. Currently writes to global config.
This commit is contained in:
4
codex-rs/Cargo.lock
generated
4
codex-rs/Cargo.lock
generated
@@ -625,6 +625,7 @@ name = "codex-cli"
|
|||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"assert_cmd",
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"codex-arg0",
|
"codex-arg0",
|
||||||
@@ -637,7 +638,10 @@ dependencies = [
|
|||||||
"codex-protocol",
|
"codex-protocol",
|
||||||
"codex-protocol-ts",
|
"codex-protocol-ts",
|
||||||
"codex-tui",
|
"codex-tui",
|
||||||
|
"predicates",
|
||||||
|
"pretty_assertions",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|||||||
@@ -38,3 +38,9 @@ tokio = { version = "1", features = [
|
|||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = "0.3.19"
|
tracing-subscriber = "0.3.19"
|
||||||
codex-protocol-ts = { path = "../protocol-ts" }
|
codex-protocol-ts = { path = "../protocol-ts" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2"
|
||||||
|
predicates = "3"
|
||||||
|
pretty_assertions = "1"
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ use codex_exec::Cli as ExecCli;
|
|||||||
use codex_tui::Cli as TuiCli;
|
use codex_tui::Cli as TuiCli;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
mod mcp_cmd;
|
||||||
|
|
||||||
|
use crate::mcp_cmd::McpCli;
|
||||||
use crate::proto::ProtoCli;
|
use crate::proto::ProtoCli;
|
||||||
|
|
||||||
/// Codex CLI
|
/// Codex CLI
|
||||||
@@ -56,8 +59,8 @@ enum Subcommand {
|
|||||||
/// Remove stored authentication credentials.
|
/// Remove stored authentication credentials.
|
||||||
Logout(LogoutCommand),
|
Logout(LogoutCommand),
|
||||||
|
|
||||||
/// Experimental: run Codex as an MCP server.
|
/// [experimental] Run Codex as an MCP server and manage MCP servers.
|
||||||
Mcp,
|
Mcp(McpCli),
|
||||||
|
|
||||||
/// Run the Protocol stream via stdin/stdout
|
/// Run the Protocol stream via stdin/stdout
|
||||||
#[clap(visible_alias = "p")]
|
#[clap(visible_alias = "p")]
|
||||||
@@ -182,9 +185,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
|||||||
);
|
);
|
||||||
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
|
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
|
||||||
}
|
}
|
||||||
Some(Subcommand::Mcp) => {
|
Some(Subcommand::Mcp(mut mcp_cli)) => {
|
||||||
codex_mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides.clone())
|
// Propagate any root-level config overrides (e.g. `-c key=value`).
|
||||||
.await?;
|
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 })) => {
|
Some(Subcommand::Resume(ResumeCommand { session_id, last })) => {
|
||||||
// Start with the parsed interactive CLI so resume shares the same
|
// Start with the parsed interactive CLI so resume shares the same
|
||||||
|
|||||||
370
codex-rs/cli/src/mcp_cmd.rs
Normal file
370
codex-rs/cli/src/mcp_cmd.rs
Normal file
@@ -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<McpSubcommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<PathBuf>) -> 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<String> = 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::<BTreeMap<_, _>>()
|
||||||
|
});
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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!(
|
||||||
|
"{:<name_w$} {:<cmd_w$} {:<args_w$} {:<env_w$}",
|
||||||
|
"Name",
|
||||||
|
"Command",
|
||||||
|
"Args",
|
||||||
|
"Env",
|
||||||
|
name_w = widths[0],
|
||||||
|
cmd_w = widths[1],
|
||||||
|
args_w = widths[2],
|
||||||
|
env_w = widths[3],
|
||||||
|
);
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
println!(
|
||||||
|
"{:<name_w$} {:<cmd_w$} {:<args_w$} {:<env_w$}",
|
||||||
|
row[0],
|
||||||
|
row[1],
|
||||||
|
row[2],
|
||||||
|
row[3],
|
||||||
|
name_w = widths[0],
|
||||||
|
cmd_w = widths[1],
|
||||||
|
args_w = widths[2],
|
||||||
|
env_w = widths[3],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> 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::<BTreeMap<_, _>>()
|
||||||
|
});
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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, '-', '_')");
|
||||||
|
}
|
||||||
|
}
|
||||||
86
codex-rs/cli/tests/mcp_add_remove.rs
Normal file
86
codex-rs/cli/tests/mcp_add_remove.rs
Normal file
@@ -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<assert_cmd::Command> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
106
codex-rs/cli/tests/mcp_list.rs
Normal file
106
codex-rs/cli/tests/mcp_list.rs
Normal file
@@ -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<assert_cmd::Command> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -25,12 +25,16 @@ use codex_protocol::mcp_protocol::Tools;
|
|||||||
use codex_protocol::mcp_protocol::UserSavedConfig;
|
use codex_protocol::mcp_protocol::UserSavedConfig;
|
||||||
use dirs::home_dir;
|
use dirs::home_dir;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
use toml::Value as TomlValue;
|
use toml::Value as TomlValue;
|
||||||
|
use toml_edit::Array as TomlArray;
|
||||||
use toml_edit::DocumentMut;
|
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_MODEL: &str = "gpt-5";
|
||||||
const OPENAI_DEFAULT_REVIEW_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<TomlValue> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn load_global_mcp_servers(
|
||||||
|
codex_home: &Path,
|
||||||
|
) -> std::io::Result<BTreeMap<String, McpServerConfig>> {
|
||||||
|
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<String, McpServerConfig>,
|
||||||
|
) -> 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::<DocumentMut>()
|
||||||
|
.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<()> {
|
fn set_project_trusted_inner(doc: &mut DocumentMut, project_path: &Path) -> anyhow::Result<()> {
|
||||||
// Ensure we render a human-friendly structure:
|
// 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]
|
#[tokio::test]
|
||||||
async fn persist_model_selection_updates_defaults() -> anyhow::Result<()> {
|
async fn persist_model_selection_updates_defaults() -> anyhow::Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
|
|||||||
123
codex-rs/docs/codex_mcp_interface.md
Normal file
123
codex-rs/docs/codex_mcp_interface.md
Normal file
@@ -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/`.
|
||||||
|
|
||||||
@@ -367,6 +367,24 @@ env = { "API_KEY" = "value" }
|
|||||||
startup_timeout_ms = 20_000
|
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
|
## 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`:
|
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`:
|
||||||
|
|||||||
Reference in New Issue
Block a user