Skip to content

Commit 1e8910b

Browse files
committed
✨ feat(mcp): add project-scoped MCP server configuration support
WHAT: Add project-scoped MCP server configuration support allowing .codex/config.toml in projects WHY: Enable fine-grained configuration management per project, avoiding global config pollution and supporting project-specific toolchain requirements HOW: Extend mcp command with --project flag; implement project config loading and merging logic; add CRUD operations for project-level configs; update documentation and README; validate functionality with unit tests
1 parent 84a0ba9 commit 1e8910b

File tree

8 files changed

+359
-5
lines changed

8 files changed

+359
-5
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ You can also use Codex with an API key, but this requires [additional setup](./d
6363

6464
### Model Context Protocol (MCP)
6565

66-
Codex CLI supports [MCP servers](./docs/advanced.md#model-context-protocol-mcp). Enable by adding an `mcp_servers` section to your `~/.codex/config.toml`.
66+
Codex CLI supports [MCP servers](./docs/advanced.md#model-context-protocol-mcp).
67+
Configure them globally via the `mcp_servers` section in `~/.codex/config.toml`,
68+
or per-project in `./.codex/config.toml` inside your project.
6769

6870

6971
### Configuration

codex-rs/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ codex-mcp-server = { path = "../mcp-server" }
2828
codex-protocol = { path = "../protocol" }
2929
codex-tui = { path = "../tui" }
3030
serde_json = "1"
31+
toml_edit = "0.23.4"
3132
tokio = { version = "1", features = [
3233
"io-std",
3334
"macros",
@@ -44,3 +45,4 @@ assert_cmd = "2"
4445
predicates = "3"
4546
pretty_assertions = "1"
4647
tempfile = "3"
48+
toml = "0.9.5"

codex-rs/cli/src/mcp_cmd.rs

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use codex_core::config::find_codex_home;
1313
use codex_core::config::load_global_mcp_servers;
1414
use codex_core::config::write_global_mcp_servers;
1515
use codex_core::config_types::McpServerConfig;
16+
use codex_core::git_info::resolve_root_git_project_for_trust;
1617

1718
/// [experimental] Launch Codex as an MCP server or manage configured MCP servers.
1819
///
@@ -78,12 +79,20 @@ pub struct AddArgs {
7879
/// Command to launch the MCP server.
7980
#[arg(trailing_var_arg = true, num_args = 1..)]
8081
pub command: Vec<String>,
82+
83+
/// Write this server to the project's `.codex/config.toml` instead of global config.
84+
#[arg(long)]
85+
pub project: bool,
8186
}
8287

8388
#[derive(Debug, clap::Parser)]
8489
pub struct RemoveArgs {
8590
/// Name of the MCP server configuration to remove.
8691
pub name: String,
92+
93+
/// Remove from the project's `.codex/config.toml` instead of global config.
94+
#[arg(long)]
95+
pub project: bool,
8796
}
8897

8998
impl McpCli {
@@ -120,7 +129,12 @@ fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<(
120129
// Validate any provided overrides even though they are not currently applied.
121130
config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
122131

123-
let AddArgs { name, env, command } = add_args;
132+
let AddArgs {
133+
name,
134+
env,
135+
command,
136+
project,
137+
} = add_args;
124138

125139
validate_server_name(&name)?;
126140

@@ -140,6 +154,26 @@ fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<(
140154
Some(map)
141155
};
142156

157+
if project {
158+
let cwd = std::env::current_dir().context("failed to get current directory")?;
159+
let project_root = resolve_root_git_project_for_trust(&cwd).unwrap_or(cwd);
160+
add_project_mcp_server(
161+
&project_root,
162+
&name,
163+
McpServerConfig {
164+
command: command_bin,
165+
args: command_args,
166+
env: env_map,
167+
startup_timeout_ms: None,
168+
},
169+
)?;
170+
println!(
171+
"Added project MCP server '{name}' in {}/.codex.",
172+
project_root.display()
173+
);
174+
return Ok(());
175+
}
176+
143177
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
144178
let mut servers = load_global_mcp_servers(&codex_home)
145179
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
@@ -164,10 +198,25 @@ fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<(
164198
fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> {
165199
config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
166200

167-
let RemoveArgs { name } = remove_args;
201+
let RemoveArgs { name, project } = remove_args;
168202

169203
validate_server_name(&name)?;
170204

205+
if project {
206+
let cwd = std::env::current_dir().context("failed to get current directory")?;
207+
let project_root = resolve_root_git_project_for_trust(&cwd).unwrap_or(cwd);
208+
let removed = remove_project_mcp_server(&project_root, &name)?;
209+
if removed {
210+
println!(
211+
"Removed project MCP server '{name}' in {}/.codex.",
212+
project_root.display()
213+
);
214+
} else {
215+
println!("No MCP server named '{name}' found.");
216+
}
217+
return Ok(());
218+
}
219+
171220
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
172221
let mut servers = load_global_mcp_servers(&codex_home)
173222
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
@@ -188,6 +237,90 @@ fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) ->
188237
Ok(())
189238
}
190239

240+
fn ensure_project_codex_path(project_root: &std::path::Path) -> Result<std::path::PathBuf> {
241+
let codex_dir = project_root.join(".codex");
242+
std::fs::create_dir_all(&codex_dir)
243+
.with_context(|| format!("failed to create {}", codex_dir.display()))?;
244+
Ok(codex_dir.join("config.toml"))
245+
}
246+
247+
fn add_project_mcp_server(
248+
project_root: &std::path::Path,
249+
name: &str,
250+
entry: McpServerConfig,
251+
) -> Result<()> {
252+
use toml_edit::{Array as TomlArray, DocumentMut, Item as TomlItem, Table as TomlTable, value};
253+
254+
let path = ensure_project_codex_path(project_root)?;
255+
let mut doc = match std::fs::read_to_string(&path) {
256+
Ok(contents) => contents.parse::<DocumentMut>().map_err(|e| anyhow!(e))?,
257+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
258+
Err(e) => return Err(anyhow!(e)),
259+
};
260+
261+
if !doc.as_table().contains_key("mcp_servers") || doc["mcp_servers"].as_table().is_none() {
262+
let mut table = TomlTable::new();
263+
table.set_implicit(true);
264+
doc["mcp_servers"] = TomlItem::Table(table);
265+
}
266+
267+
let mut entry_tbl = TomlTable::new();
268+
entry_tbl.set_implicit(false);
269+
entry_tbl["command"] = value(entry.command);
270+
271+
if !entry.args.is_empty() {
272+
let mut args = TomlArray::new();
273+
for a in entry.args {
274+
args.push(a);
275+
}
276+
entry_tbl["args"] = TomlItem::Value(args.into());
277+
}
278+
279+
if let Some(env) = entry.env {
280+
if !env.is_empty() {
281+
let mut env_tbl = TomlTable::new();
282+
env_tbl.set_implicit(false);
283+
let mut pairs: Vec<_> = env.into_iter().collect();
284+
pairs.sort_by(|(a, _), (b, _)| a.cmp(&b));
285+
for (k, v) in pairs {
286+
env_tbl.insert(&k, value(v));
287+
}
288+
entry_tbl["env"] = TomlItem::Table(env_tbl);
289+
}
290+
}
291+
292+
if let Some(timeout) = entry.startup_timeout_ms {
293+
let timeout = i64::try_from(timeout).context("startup_timeout_ms too large")?;
294+
entry_tbl["startup_timeout_ms"] = value(timeout);
295+
}
296+
297+
doc["mcp_servers"][name] = TomlItem::Table(entry_tbl);
298+
std::fs::write(&path, doc.to_string())
299+
.with_context(|| format!("failed to write {}", path.display()))?;
300+
Ok(())
301+
}
302+
303+
fn remove_project_mcp_server(project_root: &std::path::Path, name: &str) -> Result<bool> {
304+
use toml_edit::DocumentMut;
305+
let path = project_root.join(".codex").join("config.toml");
306+
let contents = match std::fs::read_to_string(&path) {
307+
Ok(c) => c,
308+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
309+
Err(e) => return Err(anyhow!(e)),
310+
};
311+
312+
let mut doc = contents.parse::<DocumentMut>().map_err(|e| anyhow!(e))?;
313+
let Some(mcp_tbl) = doc["mcp_servers"].as_table_mut() else {
314+
return Ok(false);
315+
};
316+
let removed = mcp_tbl.remove(name).is_some();
317+
if removed {
318+
std::fs::write(&path, doc.to_string())
319+
.with_context(|| format!("failed to write {}", path.display()))?;
320+
}
321+
Ok(removed)
322+
}
323+
191324
fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> {
192325
let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
193326
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())

codex-rs/cli/tests/mcp_project.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use std::path::Path;
2+
3+
use anyhow::Result;
4+
use predicates::str::contains;
5+
use tempfile::TempDir;
6+
7+
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
8+
let mut cmd = assert_cmd::Command::cargo_bin("codex")?;
9+
cmd.env("CODEX_HOME", codex_home);
10+
Ok(cmd)
11+
}
12+
13+
#[test]
14+
fn add_and_remove_project_scoped_server() -> Result<()> {
15+
let codex_home = TempDir::new()?;
16+
let project_dir = TempDir::new()?;
17+
18+
// Add project-scoped server (writes into <project>/.codex/config.toml)
19+
let mut add_cmd = codex_command(codex_home.path())?;
20+
add_cmd
21+
.current_dir(project_dir.path())
22+
.args(["mcp", "add", "docs", "--project", "--", "echo", "hello"])
23+
.assert()
24+
.success()
25+
.stdout(contains("Added project MCP server 'docs'"));
26+
27+
// Verify the project file contents
28+
let project_toml =
29+
std::fs::read_to_string(project_dir.path().join(".codex").join("config.toml"))?;
30+
let parsed: toml::Value = toml::from_str(&project_toml)?;
31+
let mcp = parsed
32+
.get("mcp_servers")
33+
.and_then(|v| v.as_table())
34+
.expect("mcp_servers table");
35+
let docs = mcp
36+
.get("docs")
37+
.and_then(|v| v.as_table())
38+
.expect("docs entry");
39+
assert_eq!(docs.get("command").and_then(|v| v.as_str()), Some("echo"));
40+
let args = docs
41+
.get("args")
42+
.and_then(|v| v.as_array())
43+
.expect("args array");
44+
assert_eq!(
45+
args.iter().map(|v| v.as_str().unwrap()).collect::<Vec<_>>(),
46+
vec!["hello"]
47+
);
48+
49+
// Remove project-scoped server
50+
let mut remove_cmd = codex_command(codex_home.path())?;
51+
remove_cmd
52+
.current_dir(project_dir.path())
53+
.args(["mcp", "remove", "docs", "--project"])
54+
.assert()
55+
.success()
56+
.stdout(contains("Removed project MCP server 'docs'"));
57+
58+
let project_toml =
59+
std::fs::read_to_string(project_dir.path().join(".codex").join("config.toml"))?;
60+
let parsed_after: toml::Value = toml::from_str(&project_toml)?;
61+
let mcp_after = parsed_after
62+
.get("mcp_servers")
63+
.and_then(|v| v.as_table())
64+
.cloned()
65+
.unwrap_or_default();
66+
assert!(!mcp_after.contains_key("docs"));
67+
68+
Ok(())
69+
}

0 commit comments

Comments
 (0)