feat: also detect .mcp.json configurations at workspace roots
This commit is contained in:
+7
-5
@@ -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
@@ -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
|
||||||
|
|||||||
+61
-19
@@ -213,11 +213,18 @@ 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]
|
[
|
||||||
.into_iter()
|
dir.join(MCP_FILE_NAME),
|
||||||
.map(|name| dir.join(name))
|
dir.join(HIDDEN_MCP_FILE_NAME),
|
||||||
.find(|candidate| candidate.is_file())
|
workspace_root.join(HIDDEN_MCP_FILE_NAME),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.is_file())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_skill_name(name: &str) -> Result<()> {
|
pub fn validate_skill_name(name: &str) -> Result<()> {
|
||||||
@@ -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"))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user