diff --git a/config.example.yaml b/config.example.yaml index 7f3093e..022072e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -136,11 +136,13 @@ enabled_mcp_servers: null # Which MCP servers to enable by default. # - slack # Example (comma-separated form): # enabled_mcp_servers: github,slack,ddg-search -no_workspace_mcp: false # Disable loading workspace-local MCP servers from .coyote/mcp.json (default: false). - # When false (the default), Coyote merges .coyote/mcp.json from the current directory - # into the global MCP registry at startup. If mcp.json is absent, Coyote falls back - # to .coyote/.mcp.json (leading dot) for compatibility with Claude-style - # configurations. Workspace entries shadow global ones on name collision. +no_workspace_mcp: false # Disable loading workspace-local MCP servers (default: false). + # When false (the default), Coyote merges the first workspace MCP config it finds + # into the global MCP registry at startup, checking in order: + # 1. .coyote/mcp.json + # 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. # ---- Skills ---- diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c115e11..f340073 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -71,7 +71,7 @@ pub struct Cli { /// Display the message without sending it #[arg(long)] 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)] pub no_workspace_mcp: bool, /// Disable memory for this invocation diff --git a/src/config/paths.rs b/src/config/paths.rs index ef2b50c..fd56d30 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -213,11 +213,18 @@ pub fn workspace_skill_file(name: &str) -> PathBuf { } pub fn workspace_mcp_config_file() -> Option { + workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default()) +} + +fn workspace_mcp_config_file_in(workspace_root: &Path) -> Option { let dir = workspace_config_dir(); - [MCP_FILE_NAME, HIDDEN_MCP_FILE_NAME] - .into_iter() - .map(|name| dir.join(name)) - .find(|candidate| candidate.is_file()) + [ + dir.join(MCP_FILE_NAME), + dir.join(HIDDEN_MCP_FILE_NAME), + workspace_root.join(HIDDEN_MCP_FILE_NAME), + ] + .into_iter() + .find(|candidate| candidate.is_file()) } pub fn validate_skill_name(name: &str) -> Result<()> { @@ -695,19 +702,20 @@ mod tests { use super::*; use serial_test::serial; - fn with_workspace_dir(f: F) { + fn with_workspace_dir(f: F) { let unique = time::SystemTime::now() .duration_since(time::UNIX_EPOCH) .unwrap() .as_nanos(); 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 prev = env::var_os(&env_name); unsafe { - env::set_var(&env_name, &root); + env::set_var(&env_name, &ws_dir); } - f(&root); + f(&root, &ws_dir); unsafe { match prev { Some(v) => env::set_var(&env_name, v), @@ -720,36 +728,70 @@ mod tests { #[test] #[serial] fn returns_none_when_no_config_exists() { - with_workspace_dir(|_| { - assert_eq!(workspace_mcp_config_file(), None); + with_workspace_dir(|root, _| { + assert_eq!(workspace_mcp_config_file_in(root), None); }); } #[test] #[serial] fn finds_mcp_json() { - with_workspace_dir(|root| { - fs::write(root.join("mcp.json"), "{}").unwrap(); - assert_eq!(workspace_mcp_config_file(), Some(root.join("mcp.json"))); + with_workspace_dir(|root, ws_dir| { + 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_claude_style_hidden_mcp_json() { - with_workspace_dir(|root| { - fs::write(root.join(".mcp.json"), "{}").unwrap(); - assert_eq!(workspace_mcp_config_file(), Some(root.join(".mcp.json"))); + with_workspace_dir(|root, ws_dir| { + 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 prefers_mcp_json_when_both_exist() { - with_workspace_dir(|root| { - fs::write(root.join("mcp.json"), "{}").unwrap(); + with_workspace_dir(|root, ws_dir| { + 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(); - 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")) + ); }); } }