Merge pull request #2770 from Hmbown/codex/harvest-2751-workspace-mcp-config
fix(mcp): harvest trusted workspace MCP config
This commit is contained in:
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user