mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user