feat: also detect .mcp.json configurations at workspace roots

This commit is contained in:
2026-07-17 14:03:08 -06:00
parent 6dd1e59815
commit 8c885d9a77
3 changed files with 69 additions and 25 deletions
+7 -5
View File
@@ -136,11 +136,13 @@ enabled_mcp_servers: null # Which MCP servers to enable by default.
# - slack # - slack
# Example (comma-separated form): # Example (comma-separated form):
# enabled_mcp_servers: github,slack,ddg-search # enabled_mcp_servers: github,slack,ddg-search
no_workspace_mcp: false # Disable loading workspace-local MCP servers from .coyote/mcp.json (default: false). no_workspace_mcp: false # Disable loading workspace-local MCP servers (default: false).
# When false (the default), Coyote merges .coyote/mcp.json from the current directory # When false (the default), Coyote merges the first workspace MCP config it finds
# into the global MCP registry at startup. If mcp.json is absent, Coyote falls back # into the global MCP registry at startup, checking in order:
# to .coyote/.mcp.json (leading dot) for compatibility with Claude-style # 1. .coyote/mcp.json
# configurations. Workspace entries shadow global ones on name collision. # 2. .coyote/.mcp.json (Claude-style file name)
# 3. .mcp.json (project root; Claude Code convention)
# Workspace entries shadow global ones on name collision.
# Set to true (or pass --no-workspace-mcp) to skip this entirely. # Set to true (or pass --no-workspace-mcp) to skip this entirely.
# ---- Skills ---- # ---- Skills ----
+1 -1
View File
@@ -71,7 +71,7 @@ pub struct Cli {
/// Display the message without sending it /// Display the message without sending it
#[arg(long)] #[arg(long)]
pub dry_run: bool, pub dry_run: bool,
/// Disable loading workspace MCP servers from .coyote/mcp.json (or .coyote/.mcp.json) /// Disable loading workspace MCP servers from .coyote/mcp.json, .coyote/.mcp.json, or .mcp.json
#[arg(long)] #[arg(long)]
pub no_workspace_mcp: bool, pub no_workspace_mcp: bool,
/// Disable memory for this invocation /// Disable memory for this invocation
+59 -17
View File
@@ -213,10 +213,17 @@ pub fn workspace_skill_file(name: &str) -> PathBuf {
} }
pub fn workspace_mcp_config_file() -> Option<PathBuf> { pub fn workspace_mcp_config_file() -> Option<PathBuf> {
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
}
fn workspace_mcp_config_file_in(workspace_root: &Path) -> Option<PathBuf> {
let dir = workspace_config_dir(); let dir = workspace_config_dir();
[MCP_FILE_NAME, HIDDEN_MCP_FILE_NAME] [
dir.join(MCP_FILE_NAME),
dir.join(HIDDEN_MCP_FILE_NAME),
workspace_root.join(HIDDEN_MCP_FILE_NAME),
]
.into_iter() .into_iter()
.map(|name| dir.join(name))
.find(|candidate| candidate.is_file()) .find(|candidate| candidate.is_file())
} }
@@ -695,19 +702,20 @@ mod tests {
use super::*; use super::*;
use serial_test::serial; use serial_test::serial;
fn with_workspace_dir<F: FnOnce(&Path)>(f: F) { fn with_workspace_dir<F: FnOnce(&Path, &Path)>(f: F) {
let unique = time::SystemTime::now() let unique = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH) .duration_since(time::UNIX_EPOCH)
.unwrap() .unwrap()
.as_nanos(); .as_nanos();
let root = env::temp_dir().join(format!("coyote-workspace-mcp-test-{unique}")); let root = env::temp_dir().join(format!("coyote-workspace-mcp-test-{unique}"));
fs::create_dir_all(&root).unwrap(); let ws_dir = root.join(WORKSPACE_COYOTE_DIR_NAME);
fs::create_dir_all(&ws_dir).unwrap();
let env_name = get_env_name("workspace_config_dir"); let env_name = get_env_name("workspace_config_dir");
let prev = env::var_os(&env_name); let prev = env::var_os(&env_name);
unsafe { unsafe {
env::set_var(&env_name, &root); env::set_var(&env_name, &ws_dir);
} }
f(&root); f(&root, &ws_dir);
unsafe { unsafe {
match prev { match prev {
Some(v) => env::set_var(&env_name, v), Some(v) => env::set_var(&env_name, v),
@@ -720,36 +728,70 @@ mod tests {
#[test] #[test]
#[serial] #[serial]
fn returns_none_when_no_config_exists() { fn returns_none_when_no_config_exists() {
with_workspace_dir(|_| { with_workspace_dir(|root, _| {
assert_eq!(workspace_mcp_config_file(), None); assert_eq!(workspace_mcp_config_file_in(root), None);
}); });
} }
#[test] #[test]
#[serial] #[serial]
fn finds_mcp_json() { fn finds_mcp_json() {
with_workspace_dir(|root| { with_workspace_dir(|root, ws_dir| {
fs::write(root.join("mcp.json"), "{}").unwrap(); fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
assert_eq!(workspace_mcp_config_file(), Some(root.join("mcp.json"))); assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join("mcp.json"))
);
}); });
} }
#[test] #[test]
#[serial] #[serial]
fn falls_back_to_claude_style_hidden_mcp_json() { fn falls_back_to_claude_style_hidden_mcp_json() {
with_workspace_dir(|root| { with_workspace_dir(|root, ws_dir| {
fs::write(root.join(".mcp.json"), "{}").unwrap(); fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
assert_eq!(workspace_mcp_config_file(), Some(root.join(".mcp.json"))); assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join(".mcp.json"))
);
}); });
} }
#[test] #[test]
#[serial] #[serial]
fn prefers_mcp_json_when_both_exist() { fn prefers_mcp_json_when_both_exist() {
with_workspace_dir(|root| { with_workspace_dir(|root, ws_dir| {
fs::write(root.join("mcp.json"), "{}").unwrap(); fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join("mcp.json"))
);
});
}
#[test]
#[serial]
fn falls_back_to_project_root_hidden_mcp_json() {
with_workspace_dir(|root, _| {
fs::write(root.join(".mcp.json"), "{}").unwrap(); fs::write(root.join(".mcp.json"), "{}").unwrap();
assert_eq!(workspace_mcp_config_file(), Some(root.join("mcp.json"))); assert_eq!(
workspace_mcp_config_file_in(root),
Some(root.join(".mcp.json"))
);
});
}
#[test]
#[serial]
fn prefers_workspace_dir_config_over_project_root() {
with_workspace_dir(|root, ws_dir| {
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
fs::write(root.join(".mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join(".mcp.json"))
);
}); });
} }
} }