feat(tui/mcp): add /mcp command for MCP status, on/off toggles, and add; persist to config and update docs\n\n- Add config helpers to list/add/enable/disable MCP servers in ~/.codex/config.toml\n- New /mcp slash command with subcommands: status, on, off, add\n- Wire command dispatch; update slash-commands docs\n- No live reload yet; suggest starting a new chat to apply

This commit is contained in:
James Peter
2025-09-08 12:44:03 +10:00
parent 60f9db8ca5
commit 7456b3f09f
5 changed files with 320 additions and 1 deletions
+175
View File
@@ -31,6 +31,7 @@ use std::path::PathBuf;
use tempfile::NamedTempFile;
use toml::Value as TomlValue;
use toml_edit::DocumentMut;
use toml_edit::Item as TomlItem;
const OPENAI_DEFAULT_MODEL: &str = "gpt-5";
@@ -425,6 +426,180 @@ pub fn set_github_check_on_push(codex_home: &Path, enabled: bool) -> anyhow::Res
Ok(())
}
/// List MCP servers from `CODEX_HOME/config.toml`.
/// Returns `(enabled, disabled)` lists of `(name, McpServerConfig)`.
pub fn list_mcp_servers(codex_home: &Path) -> anyhow::Result<(
Vec<(String, McpServerConfig)>,
Vec<(String, McpServerConfig)>,
)> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let doc_str = std::fs::read_to_string(&config_path).unwrap_or_default();
let doc = doc_str.parse::<DocumentMut>().unwrap_or_else(|_| DocumentMut::new());
fn table_to_list(tbl: &toml_edit::Table) -> Vec<(String, McpServerConfig)> {
let mut out = Vec::new();
for (name, item) in tbl.iter() {
if let Some(t) = item.as_table() {
let command = t
.get("command")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let args: Vec<String> = t
.get("args")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|i| i.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let env: Option<std::collections::HashMap<String, String>> = t
.get("env")
.and_then(|v| v.as_inline_table())
.map(|tbl| {
tbl.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.to_string(), s.to_string())))
.collect()
});
out.push((name.to_string(), McpServerConfig { command, args, env }));
}
}
out
}
let enabled = doc
.as_table()
.get("mcp_servers")
.and_then(|i| i.as_table())
.map(table_to_list)
.unwrap_or_default();
let disabled = doc
.as_table()
.get("mcp_servers_disabled")
.and_then(|i| i.as_table())
.map(table_to_list)
.unwrap_or_default();
Ok((enabled, disabled))
}
/// Add or update an MCP server under `[mcp_servers.<name>]`. If the same
/// server exists under `mcp_servers_disabled`, it will be removed from there.
pub fn add_mcp_server(
codex_home: &Path,
name: &str,
cfg: McpServerConfig,
) -> anyhow::Result<()> {
// Validate server name for safety and compatibility with MCP tool naming.
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
return Err(anyhow::anyhow!(
"invalid server name '{}': must match ^[a-zA-Z0-9_-]+$",
name
));
}
let config_path = codex_home.join(CONFIG_TOML_FILE);
let mut doc = match std::fs::read_to_string(&config_path) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
// Ensure target tables exist
if !doc.as_table().contains_key("mcp_servers") {
doc["mcp_servers"] = TomlItem::Table(toml_edit::Table::new());
}
let tbl = doc["mcp_servers"].as_table_mut().unwrap();
// Build table for this server
let mut server_tbl = toml_edit::Table::new();
server_tbl.insert("command", toml_edit::value(cfg.command));
if !cfg.args.is_empty() {
let mut arr = toml_edit::Array::new();
for a in cfg.args.into_iter() {
arr.push(toml_edit::Value::from(a));
}
server_tbl.insert("args", TomlItem::Value(toml_edit::Value::Array(arr)));
}
if let Some(env) = cfg.env {
let mut it = toml_edit::InlineTable::new();
for (k, v) in env {
it.insert(&k, toml_edit::Value::from(v));
}
server_tbl.insert("env", TomlItem::Value(toml_edit::Value::InlineTable(it)));
}
// Write into enabled table
tbl.insert(name, TomlItem::Table(server_tbl));
// Remove from disabled if present
if let Some(disabled_tbl) = doc["mcp_servers_disabled"].as_table_mut() {
disabled_tbl.remove(name);
}
// ensure codex_home exists
std::fs::create_dir_all(codex_home)?;
let tmp = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp.path(), doc.to_string())?;
tmp.persist(config_path)?;
Ok(())
}
/// Enable/disable an MCP server by moving it between `[mcp_servers]` and
/// `[mcp_servers_disabled]`. Returns `true` if a change was made.
pub fn set_mcp_server_enabled(
codex_home: &Path,
name: &str,
enabled: bool,
) -> anyhow::Result<bool> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let mut doc = match std::fs::read_to_string(&config_path) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
// Helper to ensure table exists
fn ensure_table<'a>(doc: &'a mut DocumentMut, key: &'a str) -> &'a mut toml_edit::Table {
if !doc.as_table().contains_key(key) {
doc[key] = TomlItem::Table(toml_edit::Table::new());
}
doc[key].as_table_mut().unwrap()
}
let mut changed = false;
if enabled {
// Move from disabled -> enabled
let moved = {
let disabled_tbl = ensure_table(&mut doc, "mcp_servers_disabled");
disabled_tbl.remove(name)
};
if let Some(item) = moved {
let enabled_tbl = ensure_table(&mut doc, "mcp_servers");
enabled_tbl.insert(name, item);
changed = true;
}
} else {
// Move from enabled -> disabled
let moved = {
let enabled_tbl = ensure_table(&mut doc, "mcp_servers");
enabled_tbl.remove(name)
};
if let Some(item) = moved {
let disabled_tbl = ensure_table(&mut doc, "mcp_servers_disabled");
disabled_tbl.insert(name, item);
changed = true;
}
}
if changed {
std::fs::create_dir_all(codex_home)?;
let tmp = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp.path(), doc.to_string())?;
tmp.persist(config_path)?;
}
Ok(changed)
}
/// Apply a single dotted-path override onto a TOML value.
fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
use toml::value::Table;
+5
View File
@@ -763,6 +763,11 @@ impl App<'_> {
widget.handle_github_command(command_args);
}
}
SlashCommand::Mcp => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_mcp_command(command_args);
}
}
SlashCommand::Reasoning => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_reasoning_command(command_args);
+137
View File
@@ -5546,6 +5546,143 @@ impl ChatWidget<'_> {
self.history_push(history_cell::PlainHistoryCell { lines, kind: history_cell::HistoryCellType::BackgroundEvent });
}
/// Handle `/mcp` command: manage MCP servers (status/on/off/add).
pub(crate) fn handle_mcp_command(&mut self, command_text: String) {
let trimmed = command_text.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("help") {
let help = "MCP commands:\n\
• /mcp status — list enabled/disabled servers\n\
• /mcp on <name> — enable a server (moves from disabled)\n\
• /mcp off <name> — disable a server (does not delete)\n\
• /mcp add <name> <command> [args…] [ENV=VAL…] — add or update a server\n\
Changes are saved to ~/.codex/config.toml. Start a new chat to apply.";
self.history_push(history_cell::new_background_event(help.to_string()));
return;
}
let mut parts = trimmed.split_whitespace();
let sub = parts.next().unwrap_or("");
match sub {
"status" => {
match find_codex_home() {
Ok(home) => match codex_core::config::list_mcp_servers(&home) {
Ok((enabled, disabled)) => {
let mut lines = String::new();
if enabled.is_empty() && disabled.is_empty() {
lines.push_str("No MCP servers configured. Use /mcp add … to add one.");
} else {
lines.push_str(&format!("Enabled ({}):\n", enabled.len()));
for (name, cfg) in enabled {
let args = if cfg.args.is_empty() { String::new() } else { format!(" {}", cfg.args.join(" ")) };
lines.push_str(&format!("• {} — {}{}\n", name, cfg.command, args));
}
lines.push_str(&format!("\nDisabled ({}):\n", disabled.len()));
for (name, cfg) in disabled {
let args = if cfg.args.is_empty() { String::new() } else { format!(" {}", cfg.args.join(" ")) };
lines.push_str(&format!("• {} — {}{}\n", name, cfg.command, args));
}
}
self.history_push(history_cell::new_background_event(lines));
}
Err(e) => {
let msg = format!("Failed to read MCP config: {}", e);
self.history_push(history_cell::new_error_event(msg));
}
},
Err(e) => {
let msg = format!("Failed to locate CODEX_HOME: {}", e);
self.history_push(history_cell::new_error_event(msg));
}
}
}
"on" | "off" => {
let name = parts.next().unwrap_or("");
if name.is_empty() {
let msg = format!("Usage: /mcp {} <name>", sub);
self.history_push(history_cell::new_error_event(msg));
return;
}
match find_codex_home() {
Ok(home) => match codex_core::config::set_mcp_server_enabled(&home, name, sub == "on") {
Ok(changed) => {
if changed {
// Keep ChatWidget's in-memory config roughly in sync for new sessions.
if sub == "off" { self.config.mcp_servers.remove(name); }
if sub == "on" {
// If enabling, try to load its config from disk and add to in-memory map.
if let Ok((enabled, _)) = codex_core::config::list_mcp_servers(&home) {
if let Some((_, cfg)) = enabled.into_iter().find(|(n, _)| n == name) {
self.config.mcp_servers.insert(name.to_string(), cfg);
}
}
}
let msg = format!("{} MCP server '{}'", if sub == "on" { "Enabled" } else { "Disabled" }, name);
self.history_push(history_cell::new_background_event(msg));
} else {
let msg = format!("No change: server '{}' was already {}", name, if sub == "on" { "enabled" } else { "disabled" });
self.history_push(history_cell::new_background_event(msg));
}
}
Err(e) => {
let msg = format!("Failed to update MCP server '{}': {}", name, e);
self.history_push(history_cell::new_error_event(msg));
}
},
Err(e) => {
let msg = format!("Failed to locate CODEX_HOME: {}", e);
self.history_push(history_cell::new_error_event(msg));
}
}
}
"add" => {
let name = parts.next().unwrap_or("");
let command = parts.next().unwrap_or("");
if name.is_empty() || command.is_empty() {
let msg = "Usage: /mcp add <name> <command> [args…] [ENV=VAL…]".to_string();
self.history_push(history_cell::new_error_event(msg));
return;
}
let mut args: Vec<String> = Vec::new();
let mut env: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for tok in parts {
if let Some((k, v)) = tok.split_once('=') {
// Treat token with '=' as ENV var
if !k.is_empty() { env.insert(k.to_string(), v.to_string()); }
} else {
args.push(tok.to_string());
}
}
match find_codex_home() {
Ok(home) => {
let cfg = codex_core::config_types::McpServerConfig { command: command.to_string(), args: args.clone(), env: if env.is_empty() { None } else { Some(env.clone()) } };
match codex_core::config::add_mcp_server(&home, name, cfg.clone()) {
Ok(()) => {
// Update in-memory config for future sessions
self.config.mcp_servers.insert(name.to_string(), cfg);
let args_disp = if args.is_empty() { String::new() } else { format!(" {}", args.join(" ")) };
let msg = format!("Added MCP server '{}': {}{}", name, command, args_disp);
self.history_push(history_cell::new_background_event(msg));
}
Err(e) => {
let msg = format!("Failed to add MCP server '{}': {}", name, e);
self.history_push(history_cell::new_error_event(msg));
}
}
}
Err(e) => {
let msg = format!("Failed to locate CODEX_HOME: {}", e);
self.history_push(history_cell::new_error_event(msg));
}
}
}
_ => {
let msg = format!("Unknown MCP command: '{}'\nUsage:\n /mcp status\n /mcp on <name>\n /mcp off <name>\n /mcp add <name> <command> [args…] [ENV=VAL…]", sub);
self.history_push(history_cell::new_error_event(msg));
}
}
}
#[allow(dead_code)]
fn switch_to_internal_browser(&mut self) {
// Switch to internal browser mode
+2
View File
@@ -32,6 +32,7 @@ pub enum SlashCommand {
Agents,
Branch,
Github,
Mcp,
Resume,
// Prompt-expanding commands
Plan,
@@ -67,6 +68,7 @@ impl SlashCommand {
SlashCommand::Agents => "list agents (running and availability)",
SlashCommand::Branch => "create a worktree branch and switch (/branch [task])",
SlashCommand::Github => "GitHub Actions watcher (status/on/off)",
SlashCommand::Mcp => "manage MCP servers (status/on/off/add)",
SlashCommand::Perf => "performance tracing (on/off/show/reset)",
SlashCommand::Logout => "log out of Code",
#[cfg(debug_assertions)]
+1 -1
View File
@@ -34,6 +34,7 @@ Notes
- `/reasoning (minimal|low|medium|high)`: change reasoning effort.
- `/prompts`: show example prompts.
- `/status`: show current session configuration and token usage.
- `/mcp`: manage MCP servers (status/on/off/add).
## Search & Mentions
@@ -64,4 +65,3 @@ Implementation Notes
new command, please update this document to keep the UI and docs in sync.
- Prompt formatting for `/plan`, `/solve`, and `/code` lives in
`codex-rs/core/src/slash_commands.rs`.