Merge pull request #2770 from Hmbown/codex/harvest-2751-workspace-mcp-config

fix(mcp): harvest trusted workspace MCP config
This commit is contained in:
Hunter Bown
2026-06-04 20:32:13 -07:00
committed by GitHub
4 changed files with 621 additions and 112 deletions
+5 -2
View File
@@ -2270,8 +2270,11 @@ In {new} mode: {policy}\n\n\
if let Some(pool) = self.mcp_pool.as_ref() {
return Ok(Arc::clone(pool));
}
let mut pool = McpPool::from_config_path(&self.session.mcp_config_path)
.map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))?;
let mut pool = McpPool::from_config_path_with_workspace(
&self.session.mcp_config_path,
&self.session.workspace,
)
.map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))?;
if let Some(decider) = self.config.network_policy.as_ref() {
pool = pool.with_network_policy(decider.clone());
}
+100 -61
View File
@@ -1027,7 +1027,8 @@ async fn main() -> Result<()> {
Commands::Eval(args) => run_eval(args),
Commands::Mcp { command } => {
let config = load_config_from_cli(&cli)?;
run_mcp_command(&config, command).await
let workspace = resolve_workspace(&cli);
run_mcp_command(&config, &workspace, command).await
}
Commands::Execpolicy(command) => {
let config = load_config_from_cli(&cli)?;
@@ -1540,6 +1541,7 @@ fn mcp_template_json() -> Result<String> {
command: Some("node".to_string()),
args: vec!["./path/to/your-mcp-server.js".to_string()],
env: std::collections::HashMap::new(),
cwd: None,
url: None,
transport: None,
connect_timeout: None,
@@ -2078,14 +2080,21 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
println!(" · default_text_model: {model}");
let mcp_path = config.mcp_config_path();
let mcp_count = match load_mcp_config(&mcp_path) {
let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace);
let mcp_count = match crate::mcp::load_config_with_workspace(&mcp_path, workspace) {
Ok(cfg) => cfg.servers.len(),
Err(_) => 0,
};
let mcp_present = if mcp_path.exists() { "" } else { " (missing)" };
let project_mcp_present = if project_mcp_path.exists() {
""
} else {
" (missing)"
};
println!(
" · mcp servers: {mcp_count} at {}{mcp_present}",
mcp_path.display()
" · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
mcp_path.display(),
project_mcp_path.display()
);
let skills_dir = config.skills_dir();
@@ -2575,68 +2584,85 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
}
let mcp_config_path = config.mcp_config_path();
let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
if mcp_config_path.exists() {
println!(
" {} MCP config found at {}",
"".truecolor(aqua_r, aqua_g, aqua_b),
crate::utils::display_path(&mcp_config_path)
);
match load_mcp_config(&mcp_config_path) {
Ok(cfg) if cfg.servers.is_empty() => {
println!(" {} 0 server(s) configured", "·".dimmed());
}
Ok(cfg) => {
println!(
" {} {} server(s) configured",
"·".dimmed(),
cfg.servers.len()
);
for (name, server) in &cfg.servers {
let status = doctor_check_mcp_server(server);
let icon = match status {
McpServerDoctorStatus::Ok(ref detail) => {
format!(
" {} {name}: {}",
"".truecolor(aqua_r, aqua_g, aqua_b),
detail
)
}
McpServerDoctorStatus::Warning(ref detail) => {
format!(
" {} {name}: {}",
"!".truecolor(sky_r, sky_g, sky_b),
detail
)
}
McpServerDoctorStatus::Error(ref detail) => {
format!(
" {} {name}: {}",
"".truecolor(red_r, red_g, red_b),
detail
)
}
};
println!("{icon}");
if !server.enabled {
println!(" (disabled)");
}
}
}
Err(err) => {
println!(
" {} MCP config parse error: {}",
"".truecolor(red_r, red_g, red_b),
err
);
}
}
} else {
println!(
" {} MCP config not found at {}",
"·".dimmed(),
crate::utils::display_path(&mcp_config_path)
);
println!(" Run `codewhale mcp init` or `codewhale setup --mcp`.");
}
if project_mcp_config_path.exists() {
println!(
" {} Project MCP config found at {}",
"".truecolor(aqua_r, aqua_g, aqua_b),
crate::utils::display_path(&project_mcp_config_path)
);
} else {
println!(
" {} Project MCP config not found at {}",
"·".dimmed(),
crate::utils::display_path(&project_mcp_config_path)
);
}
match crate::mcp::load_config_with_workspace(&mcp_config_path, workspace) {
Ok(cfg) if cfg.servers.is_empty() => {
println!(" {} 0 merged server(s) configured", "·".dimmed());
if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
println!(" Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
}
}
Ok(cfg) => {
println!(
" {} {} merged server(s) configured",
"·".dimmed(),
cfg.servers.len()
);
for (name, server) in &cfg.servers {
let status = doctor_check_mcp_server(server);
let icon = match status {
McpServerDoctorStatus::Ok(ref detail) => {
format!(
" {} {name}: {}",
"".truecolor(aqua_r, aqua_g, aqua_b),
detail
)
}
McpServerDoctorStatus::Warning(ref detail) => {
format!(
" {} {name}: {}",
"!".truecolor(sky_r, sky_g, sky_b),
detail
)
}
McpServerDoctorStatus::Error(ref detail) => {
format!(
" {} {name}: {}",
"".truecolor(red_r, red_g, red_b),
detail
)
}
};
println!("{icon}");
if !server.enabled {
println!(" (disabled)");
}
}
}
Err(err) => {
println!(
" {} MCP config parse error: {}",
"".truecolor(red_r, red_g, red_b),
err
);
}
}
// Skills configuration
@@ -3144,8 +3170,10 @@ fn run_doctor_json(
};
let mcp_config_path = config.mcp_config_path();
let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
let mcp_present = mcp_config_path.exists();
let mcp_summary = match load_mcp_config(&mcp_config_path) {
let project_mcp_present = project_mcp_config_path.exists();
let mcp_summary = match crate::mcp::load_config_with_workspace(&mcp_config_path, workspace) {
Ok(cfg) => {
let servers: Vec<serde_json::Value> = cfg
.servers
@@ -3168,12 +3196,16 @@ fn run_doctor_json(
json!({
"config_path": mcp_config_path.display().to_string(),
"present": mcp_present,
"project_config_path": project_mcp_config_path.display().to_string(),
"project_present": project_mcp_present,
"servers": servers,
})
}
Err(err) => json!({
"config_path": mcp_config_path.display().to_string(),
"present": mcp_present,
"project_config_path": project_mcp_config_path.display().to_string(),
"project_present": project_mcp_present,
"servers": [],
"error": err.to_string(),
}),
@@ -4440,7 +4472,7 @@ fn read_patch_from_stdin() -> Result<String> {
Ok(buffer)
}
async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
async fn run_mcp_command(config: &Config, workspace: &Path, command: McpCommand) -> Result<()> {
let config_path = config.mcp_config_path();
match command {
McpCommand::Init { force } => {
@@ -4463,9 +4495,13 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
Ok(())
}
McpCommand::List => {
let cfg = load_mcp_config(&config_path)?;
let cfg = crate::mcp::load_config_with_workspace(&config_path, workspace)?;
if cfg.servers.is_empty() {
println!("No MCP servers configured in {}", config_path.display());
println!(
"No MCP servers configured in {} or {}",
config_path.display(),
crate::mcp::workspace_mcp_config_path(workspace).display()
);
return Ok(());
}
println!("MCP servers ({}):", cfg.servers.len());
@@ -4493,7 +4529,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
Ok(())
}
McpCommand::Connect { server } => {
let mut pool = McpPool::from_config_path(&config_path)?;
let mut pool = McpPool::from_config_path_with_workspace(&config_path, workspace)?;
if let Some(name) = server {
pool.get_or_connect(&name).await?;
println!("Connected to MCP server: {name}");
@@ -4510,7 +4546,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
Ok(())
}
McpCommand::Tools { server } => {
let mut pool = McpPool::from_config_path(&config_path)?;
let mut pool = McpPool::from_config_path_with_workspace(&config_path, workspace)?;
if let Some(name) = server {
let conn = pool.get_or_connect(&name).await?;
if conn.tools().is_empty() {
@@ -4569,6 +4605,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
command,
args,
env: std::collections::HashMap::new(),
cwd: None,
url,
transport,
connect_timeout: None,
@@ -4620,7 +4657,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
Ok(())
}
McpCommand::Validate => {
let mut pool = McpPool::from_config_path(&config_path)?;
let mut pool = McpPool::from_config_path_with_workspace(&config_path, workspace)?;
let errors = pool.connect_all().await;
if errors.is_empty() {
println!("MCP config is valid. All enabled servers connected.");
@@ -4656,6 +4693,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
command: Some(exe_str.clone()),
args,
env: std::collections::HashMap::new(),
cwd: None,
url: None,
transport: None,
connect_timeout: None,
@@ -7472,6 +7510,7 @@ mod doctor_mcp_tests {
command: command.map(String::from),
args: args.iter().map(|s| s.to_string()).collect(),
env: std::collections::HashMap::new(),
cwd: None,
url: url.map(String::from),
transport: None,
connect_timeout: None,
+510 -40
View File
@@ -7,7 +7,7 @@
use std::collections::{HashMap, VecDeque};
use std::fs;
use std::path::{Component, Path};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
@@ -263,6 +263,9 @@ pub struct McpServerConfig {
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
pub url: Option<String>,
/// Optional explicit HTTP transport override.
///
@@ -1391,6 +1394,9 @@ impl McpConnection {
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = &config.cwd {
cmd.current_dir(cwd);
}
// MCP stdio servers are user-configured integrations. Use the
// wider MCP allowlist so common Node/Python/proxy/CA-bundle
@@ -1899,19 +1905,18 @@ pub struct McpPool {
connections: HashMap<String, McpConnection>,
config: McpConfig,
network_policy: Option<NetworkPolicyDecider>,
/// Source path the config was loaded from, when `from_config_path` was
/// used. `None` for pools constructed directly via `new` (tests, ad-hoc
/// snapshots). Drives the lazy-reload check (#1267 part 2): when the
/// file's mtime moves, the pool re-reads the config and compares its
/// content hash to decide whether to drop existing connections.
config_source: Option<std::path::PathBuf>,
/// Source paths the config was loaded from. Empty for pools constructed
/// directly via `new` (tests, ad-hoc snapshots). Workspace-aware pools
/// track both global and project-level MCP config paths so lazy reload sees
/// either file appear or change.
config_sources: Vec<PathBuf>,
workspace: Option<PathBuf>,
/// 64-bit content hash of the active config (`hash_mcp_config`). Compared
/// against the freshly-loaded config after an mtime change to skip
/// reloading when the file was merely touched.
config_hash: u64,
/// Most recently observed mtime of `config_source`. Updated whenever the
/// reload check runs (whether or not it triggered a reload).
last_mtime: Option<std::time::SystemTime>,
/// Most recently observed mtime for `config_sources`.
last_mtimes: Vec<Option<std::time::SystemTime>>,
}
impl McpPool {
@@ -1922,27 +1927,41 @@ impl McpPool {
connections: HashMap::new(),
config,
network_policy: None,
config_source: None,
config_sources: Vec::new(),
workspace: None,
config_hash,
last_mtime: None,
last_mtimes: Vec::new(),
}
}
/// Create a pool from a configuration file path
/// Create a pool from a configuration file path.
#[cfg(test)]
pub fn from_config_path(path: &std::path::Path) -> Result<Self> {
validate_mcp_config_path(path)?;
let config = if path.exists() {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read MCP config: {}", path.display()))?;
serde_json::from_str(&contents)
.with_context(|| format!("Failed to parse MCP config: {}", path.display()))?
} else {
McpConfig::default()
};
let last_mtime = mcp_config_mtime(path);
let config = load_config(path)?;
let mut pool = Self::new(config);
pool.config_source = Some(path.to_path_buf());
pool.last_mtime = last_mtime;
pool.config_sources = vec![path.to_path_buf()];
pool.last_mtimes = vec![mcp_config_mtime(path)];
Ok(pool)
}
/// Create a pool from global MCP config plus workspace-local
/// `.codewhale/mcp.json`. Project servers override same-name global
/// servers and default stdio `cwd` to the workspace root.
pub fn from_config_path_with_workspace(
path: &std::path::Path,
workspace: &Path,
) -> Result<Self> {
let config = load_config_with_workspace(path, workspace)?;
let mut pool = Self::new(config);
pool.config_sources = vec![path.to_path_buf(), workspace_mcp_config_path(workspace)];
pool.config_sources
.extend(crate::config::workspace_trust_config_candidate_paths());
pool.last_mtimes = pool
.config_sources
.iter()
.map(|source| mcp_config_mtime(source))
.collect();
pool.workspace = Some(workspace.to_path_buf());
Ok(pool)
}
@@ -1967,29 +1986,31 @@ impl McpPool {
/// or remote filesystems where mtime granularity is poor, the hash
/// compare keeps us from churning connections on every check.
pub async fn reload_if_config_changed(&mut self) -> Result<bool> {
let Some(path) = self.config_source.clone() else {
if self.config_sources.is_empty() {
return Ok(false);
};
let current_mtime = match mcp_config_mtime(&path) {
Some(m) => m,
None => return Ok(false),
};
if Some(current_mtime) == self.last_mtime {
}
let current_mtimes: Vec<_> = self
.config_sources
.iter()
.map(|path| mcp_config_mtime(path))
.collect();
if current_mtimes == self.last_mtimes {
return Ok(false);
}
// mtime moved — we owe a re-read.
let new_config: McpConfig = if path.exists() {
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to re-read MCP config: {}", path.display()))?;
serde_json::from_str(&contents)
.with_context(|| format!("Failed to re-parse MCP config: {}", path.display()))?
let primary = self
.config_sources
.first()
.context("MCP config source list unexpectedly empty")?;
let new_config = if let Some(workspace) = self.workspace.as_deref() {
load_config_with_workspace(primary, workspace)?
} else {
McpConfig::default()
load_config(primary)?
};
let new_hash = hash_mcp_config(&new_config);
// Always advance last_mtime so a touched-but-unchanged file doesn't
// Always advance mtimes so a touched-but-unchanged file doesn't
// make us re-read on every subsequent call.
self.last_mtime = Some(current_mtime);
self.last_mtimes = current_mtimes;
if new_hash == self.config_hash {
return Ok(false);
}
@@ -2604,6 +2625,95 @@ pub fn load_config(path: &Path) -> Result<McpConfig> {
.with_context(|| format!("Failed to parse MCP config {}", path.display()))
}
pub fn workspace_mcp_config_path(workspace: &Path) -> PathBuf {
normalize_workspace_path(workspace)
.join(".codewhale")
.join("mcp.json")
}
pub fn load_config_with_workspace(global_path: &Path, workspace: &Path) -> Result<McpConfig> {
let mut merged = load_config(global_path)?;
let workspace = normalize_workspace_path(workspace);
let project_path = workspace_mcp_config_path(&workspace);
if !project_path.exists() || paths_refer_to_same_config(global_path, &project_path) {
return Ok(merged);
}
// Workspace-local MCP can spawn stdio servers, so it is only honored after
// the user has trusted this workspace in user-owned config. Do not accept
// project-local legacy trust markers here: a repository could carry those
// files itself and silently reintroduce the project-scope `mcp_config_path`
// risk denied in #417.
if !workspace_allows_project_mcp_config(&workspace) {
return Ok(merged);
}
let mut project = load_config(&project_path)?;
for server in project.servers.values_mut() {
if server.command.is_some() && server.url.is_none() {
let cwd = match server.cwd.as_deref() {
Some(cwd) if cwd.is_relative() => normalize_path_components(&workspace.join(cwd)),
Some(cwd) => normalize_path_components(cwd),
None => workspace.to_path_buf(),
};
if !cwd.starts_with(&workspace) {
anyhow::bail!(
"Project MCP server cwd must stay within workspace: {}",
cwd.display()
);
}
server.cwd = Some(cwd);
}
}
merged.servers.extend(project.servers);
Ok(merged)
}
fn workspace_allows_project_mcp_config(workspace: &Path) -> bool {
crate::config::is_workspace_trusted(workspace)
}
fn normalize_workspace_path(workspace: &Path) -> PathBuf {
if let Ok(canonical) = workspace.canonicalize() {
return canonical;
}
let absolute = if workspace.is_absolute() {
workspace.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(workspace)
};
normalize_path_components(&absolute)
}
fn normalize_path_components(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
normalized.push(component.as_os_str());
}
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Normal(part) => normalized.push(part),
}
}
if normalized.as_os_str().is_empty() {
PathBuf::from(".")
} else {
normalized
}
}
fn paths_refer_to_same_config(left: &Path, right: &Path) -> bool {
match (left.canonicalize(), right.canonicalize()) {
(Ok(left), Ok(right)) => left == right,
_ => normalize_workspace_path(left) == normalize_workspace_path(right),
}
}
/// 64-bit content hash of an [`McpConfig`]. Used by [`McpPool`] to decide
/// whether a freshly-read config differs from the one currently driving the
/// live connections. Hashing the JSON serialization avoids forcing every
@@ -2654,6 +2764,7 @@ fn mcp_template_json() -> Result<String> {
command: Some("node".to_string()),
args: vec!["./path/to/your-mcp-server.js".to_string()],
env: HashMap::new(),
cwd: None,
url: None,
transport: None,
connect_timeout: None,
@@ -2709,6 +2820,7 @@ pub fn add_server_config(
command,
args,
env: HashMap::new(),
cwd: None,
url,
transport,
connect_timeout: None,
@@ -2952,6 +3064,56 @@ mod tests {
.await
}
struct WorkspaceTrustConfigGuard {
config_path: PathBuf,
_codewhale_config_path: crate::test_support::EnvVarGuard,
_deepseek_config_path: crate::test_support::EnvVarGuard,
_env_lock: std::sync::MutexGuard<'static, ()>,
}
fn workspace_trust_config_guard(workspace: &Path) -> WorkspaceTrustConfigGuard {
let env_lock = crate::test_support::lock_test_env();
let config_path = workspace
.parent()
.unwrap_or(workspace)
.join("user-config")
.join("config.toml");
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let codewhale_config_path =
crate::test_support::EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
let deepseek_config_path = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
WorkspaceTrustConfigGuard {
config_path,
_codewhale_config_path: codewhale_config_path,
_deepseek_config_path: deepseek_config_path,
_env_lock: env_lock,
}
}
fn write_workspace_trust_config(config_path: &Path, workspace: &Path) {
let workspace = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
let key = workspace
.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"");
fs::write(
config_path,
format!("[projects.\"{key}\"]\ntrust_level = \"trusted\"\n"),
)
.unwrap();
}
fn mark_workspace_trusted(workspace: &Path) -> WorkspaceTrustConfigGuard {
let guard = workspace_trust_config_guard(workspace);
write_workspace_trust_config(&guard.config_path, workspace);
guard
}
#[test]
fn test_mcp_config_defaults() {
let config = McpConfig::default();
@@ -3021,6 +3183,7 @@ mod tests {
command: Some("node".into()),
args: vec!["server.js".into()],
env: HashMap::new(),
cwd: None,
url: None,
transport: None,
connect_timeout: None,
@@ -3157,6 +3320,307 @@ mod tests {
assert_eq!(snapshot.servers[0].error.as_deref(), Some("disabled"));
}
#[test]
fn workspace_mcp_config_merges_with_project_overrides() {
let dir = tempfile::tempdir().unwrap();
let global_path = dir.path().join("global-mcp.json");
let workspace = dir.path().join("workspace");
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir).unwrap();
let _trust = mark_workspace_trusted(&workspace);
fs::write(
&global_path,
r#"{
"servers": {
"global": {"command": "node", "args": ["global.js"]},
"shared": {"command": "node", "args": ["global-shared.js"]}
}
}"#,
)
.unwrap();
fs::write(
project_dir.join("mcp.json"),
r#"{
"servers": {
"project": {"command": "php", "args": ["artisan", "boost:mcp"]},
"shared": {"command": "php", "args": ["artisan", "shared:mcp"]}
}
}"#,
)
.unwrap();
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
let workspace = workspace.canonicalize().unwrap();
assert!(cfg.servers.contains_key("global"));
let project = cfg.servers.get("project").unwrap();
assert_eq!(project.command.as_deref(), Some("php"));
assert_eq!(project.cwd.as_deref(), Some(workspace.as_path()));
let shared = cfg.servers.get("shared").unwrap();
assert_eq!(shared.args, vec!["artisan", "shared:mcp"]);
assert_eq!(shared.cwd.as_deref(), Some(workspace.as_path()));
}
#[test]
fn workspace_mcp_config_ignores_project_file_until_workspace_trusted() {
let dir = tempfile::tempdir().unwrap();
let global_path = dir.path().join("global-mcp.json");
let workspace = dir.path().join("workspace");
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir).unwrap();
fs::write(
&global_path,
r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
)
.unwrap();
fs::write(
project_dir.join("mcp.json"),
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
)
.unwrap();
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
assert!(cfg.servers.contains_key("global"));
assert!(!cfg.servers.contains_key("project"));
}
#[test]
fn workspace_mcp_config_ignores_project_local_legacy_trust_marker() {
let dir = tempfile::tempdir().unwrap();
let global_path = dir.path().join("global-mcp.json");
let workspace = dir.path().join("workspace");
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir).unwrap();
fs::create_dir_all(workspace.join(".deepseek")).unwrap();
fs::write(workspace.join(".deepseek").join("trusted"), "").unwrap();
fs::write(
&global_path,
r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
)
.unwrap();
fs::write(
project_dir.join("mcp.json"),
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
)
.unwrap();
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
assert!(cfg.servers.contains_key("global"));
assert!(!cfg.servers.contains_key("project"));
}
#[test]
fn workspace_mcp_config_ignores_invalid_untrusted_project_file() {
let dir = tempfile::tempdir().unwrap();
let global_path = dir.path().join("global-mcp.json");
let workspace = dir.path().join("workspace");
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir).unwrap();
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
fs::write(project_dir.join("mcp.json"), "{ not json").unwrap();
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
assert!(cfg.servers.is_empty());
}
#[test]
fn workspace_mcp_config_normalizes_parent_components() {
let dir = tempfile::tempdir().unwrap();
let global_path = dir.path().join("global-mcp.json");
let workspace = dir.path().join("workspace");
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir).unwrap();
let _trust = mark_workspace_trusted(&workspace);
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
fs::write(
project_dir.join("mcp.json"),
r#"{"servers": {"project": {"command": "node", "args": ["server.js"]}}}"#,
)
.unwrap();
let workspace_with_parent = workspace.join("..").join("workspace");
let cfg = load_config_with_workspace(&global_path, &workspace_with_parent).unwrap();
let workspace = workspace.canonicalize().unwrap();
assert!(cfg.servers.contains_key("project"));
let project = cfg.servers.get("project").unwrap();
assert_eq!(project.cwd.as_deref(), Some(workspace.as_path()));
}
#[test]
fn workspace_mcp_config_resolves_relative_cwd_from_workspace() {
let dir = tempfile::tempdir().unwrap();
let global_path = dir.path().join("global-mcp.json");
let workspace = dir.path().join("workspace");
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir).unwrap();
let _trust = mark_workspace_trusted(&workspace);
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
fs::write(
project_dir.join("mcp.json"),
r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "tools/mcp"}}}"#,
)
.unwrap();
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
let workspace = workspace.canonicalize().unwrap();
let project = cfg.servers.get("project").unwrap();
assert_eq!(
project.cwd.as_deref(),
Some(workspace.join("tools/mcp").as_path())
);
}
#[test]
fn workspace_mcp_config_rejects_project_cwd_escape() {
let dir = tempfile::tempdir().unwrap();
let global_path = dir.path().join("global-mcp.json");
let workspace = dir.path().join("workspace");
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir).unwrap();
let _trust = mark_workspace_trusted(&workspace);
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
fs::write(
project_dir.join("mcp.json"),
r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "../outside"}}}"#,
)
.unwrap();
let err = load_config_with_workspace(&global_path, &workspace)
.expect_err("project MCP cwd escape must be rejected");
assert!(
err.to_string()
.contains("Project MCP server cwd must stay within workspace"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn workspace_mcp_pool_reload_picks_up_project_config_creation() {
let dir = tempfile::tempdir().unwrap();
let global_path = dir.path().join("global-mcp.json");
let workspace = dir.path().join("workspace");
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&workspace).unwrap();
let _trust = mark_workspace_trusted(&workspace);
fs::write(
&global_path,
r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
)
.unwrap();
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
assert_eq!(pool.server_names(), vec!["global"]);
fs::create_dir_all(&project_dir).unwrap();
fs::write(
project_dir.join("mcp.json"),
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
)
.unwrap();
assert!(pool.reload_if_config_changed().await.unwrap());
let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect();
let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect();
assert_eq!(names, expected);
}
#[tokio::test]
async fn workspace_mcp_pool_reload_picks_up_project_config_after_workspace_trust() {
let dir = tempfile::tempdir().unwrap();
let global_path = dir.path().join("global-mcp.json");
let workspace = dir.path().join("workspace");
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir).unwrap();
let trust_env = workspace_trust_config_guard(&workspace);
fs::write(
&global_path,
r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
)
.unwrap();
fs::write(
project_dir.join("mcp.json"),
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
)
.unwrap();
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
assert_eq!(pool.server_names(), vec!["global"]);
write_workspace_trust_config(&trust_env.config_path, &workspace);
assert!(pool.reload_if_config_changed().await.unwrap());
let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect();
let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect();
assert_eq!(names, expected);
}
#[tokio::test]
async fn workspace_mcp_pool_reload_drops_project_config_after_workspace_trust_removed() {
let dir = tempfile::tempdir().unwrap();
let global_path = dir.path().join("global-mcp.json");
let workspace = dir.path().join("workspace");
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir).unwrap();
let trust = mark_workspace_trusted(&workspace);
fs::write(
&global_path,
r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
)
.unwrap();
fs::write(
project_dir.join("mcp.json"),
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
)
.unwrap();
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect();
let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect();
assert_eq!(names, expected);
fs::remove_file(&trust.config_path).unwrap();
assert!(pool.reload_if_config_changed().await.unwrap());
assert_eq!(pool.server_names(), vec!["global"]);
}
#[tokio::test]
async fn workspace_mcp_pool_reload_drops_project_config_after_deletion() {
let dir = tempfile::tempdir().unwrap();
let global_path = dir.path().join("global-mcp.json");
let workspace = dir.path().join("workspace");
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir).unwrap();
let _trust = mark_workspace_trusted(&workspace);
fs::write(
&global_path,
r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
)
.unwrap();
let project_path = project_dir.join("mcp.json");
fs::write(
&project_path,
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
)
.unwrap();
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect();
let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect();
assert_eq!(names, expected);
fs::remove_file(project_path).unwrap();
assert!(pool.reload_if_config_changed().await.unwrap());
assert_eq!(pool.server_names(), vec!["global"]);
}
#[test]
fn test_mcp_config_rejects_traversal_path() {
let err = load_config(Path::new("../mcp.json")).expect_err("traversal path should fail");
@@ -3257,6 +3721,7 @@ mod tests {
command: Some("test".to_string()),
args: vec![],
env: HashMap::new(),
cwd: None,
url: None,
transport: None,
connect_timeout: Some(20),
@@ -3368,6 +3833,7 @@ mod tests {
command: Some("mock".to_string()),
args: Vec::new(),
env: HashMap::new(),
cwd: None,
url: None,
transport: None,
connect_timeout: None,
@@ -3557,6 +4023,7 @@ mod tests {
command: Some("/bin/echo".into()),
args: vec!["hi".into()],
env: Default::default(),
cwd: None,
url: None,
transport: None,
connect_timeout: None,
@@ -4081,6 +4548,7 @@ mod tests {
command: None,
args: vec![],
env: HashMap::new(),
cwd: None,
url: Some(format!("http://{addr}/mcp")),
transport: None,
connect_timeout: Some(2),
@@ -4829,6 +5297,7 @@ mod tests {
command: None,
args: Vec::new(),
env: HashMap::new(),
cwd: None,
url: Some(format!("http://{addr}/mcp")),
transport: None,
connect_timeout: Some(10),
@@ -5100,6 +5569,7 @@ mod tests {
command: None,
args: Vec::new(),
env: HashMap::new(),
cwd: None,
url: Some(format!("http://{addr}/sse")),
transport: Some("sse".to_string()),
connect_timeout: Some(10),
+6 -9
View File
@@ -34,7 +34,7 @@ use crate::automation_manager::{
CreateAutomationRequest, SharedAutomationManager, UpdateAutomationRequest, spawn_scheduler,
};
use crate::config::{Config, DEFAULT_TEXT_MODEL};
use crate::mcp::{McpConfig, McpPool};
use crate::mcp::McpPool;
use crate::models::{ContentBlock, Message};
use crate::runtime_threads::{
CompactThreadRequest, CreateThreadRequest, ExternalApprovalDecision, RuntimeThreadManager,
@@ -1388,7 +1388,8 @@ async fn runtime_info(State(state): State<RuntimeApiState>) -> Json<RuntimeInfoR
async fn list_mcp_servers(
State(state): State<RuntimeApiState>,
) -> Result<Json<McpServersResponse>, ApiError> {
let config = load_mcp_config_or_default(&state.mcp_config_path)?;
let config = crate::mcp::load_config_with_workspace(&state.mcp_config_path, &state.workspace)
.map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
let mut pool = McpPool::new(config.clone());
let _errors = pool.connect_all().await;
let connected: HashSet<String> = pool
@@ -1419,8 +1420,9 @@ async fn list_mcp_tools(
State(state): State<RuntimeApiState>,
Query(query): Query<McpToolsQuery>,
) -> Result<Json<McpToolsResponse>, ApiError> {
let mut pool = McpPool::from_config_path(&state.mcp_config_path)
.map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
let mut pool =
McpPool::from_config_path_with_workspace(&state.mcp_config_path, &state.workspace)
.map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
let _errors = pool.connect_all().await;
let mut tools = Vec::new();
@@ -2126,11 +2128,6 @@ fn format_skill_search_paths(directories: &[PathBuf]) -> String {
.join(", ")
}
fn load_mcp_config_or_default(path: &std::path::Path) -> Result<McpConfig, ApiError> {
crate::mcp::load_config(path)
.map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e:#}")))
}
#[derive(Debug, Deserialize)]
struct UsageQuery {
/// ISO-8601 lower bound (inclusive). When omitted, no lower bound.