fix(mcp): harvest trusted workspace MCP config
Merge global MCP config with trusted workspace .codewhale/mcp.json files so project MCP servers appear in TUI, CLI, doctor, and runtime API flows. Project stdio servers default cwd to the workspace, project cwd escapes are rejected, and project MCP is ignored until workspace trust is recorded in user-owned config. Fixes #2749 Reported by @yekern Harvested from PR #2751 by @cyq1017
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