From 6958e9cba85334e334e31ac49747a725f2af66dc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 17 Jul 2026 10:46:26 -0600 Subject: [PATCH] feat: Support claude-style hidden workspace MCP configuration files via .mcp.json --- config.example.yaml | 6 ++-- src/cli/mod.rs | 2 +- src/config/mod.rs | 1 + src/config/paths.rs | 81 ++++++++++++++++++++++++++++++++++++++++----- src/mcp/mod.rs | 69 +++++++++++++++++++------------------- 5 files changed, 113 insertions(+), 46 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index d466675..7f3093e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -138,8 +138,10 @@ enabled_mcp_servers: null # Which MCP servers to enable by default. # 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. Workspace entries shadow global ones on - # name collision. Set to true (or pass --no-workspace-mcp) to skip this entirely. + # 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. + # Set to true (or pass --no-workspace-mcp) to skip this entirely. # ---- Skills ---- # Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c931781..c115e11 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 + /// Disable loading workspace MCP servers from .coyote/mcp.json (or .coyote/.mcp.json) #[arg(long)] pub no_workspace_mcp: bool, /// Disable memory for this invocation diff --git a/src/config/mod.rs b/src/config/mod.rs index 4839b2c..c29994e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -140,6 +140,7 @@ const GLOBAL_TOOLS_DIR_NAME: &str = "tools"; const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils"; const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh"; const MCP_FILE_NAME: &str = "mcp.json"; +const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json"; const MEMORY_DIR_NAME: &str = "memory"; const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md"; const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md"; diff --git a/src/config/paths.rs b/src/config/paths.rs index 5a82519..ef2b50c 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -2,10 +2,10 @@ use super::role::Role; use super::{ AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME, ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME, - GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, - MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, - SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME, - SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME, + GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, + MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, + SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, + SBX_VAULT_MIXINS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME, }; use crate::client::ProviderModels; use crate::config::REPL_HISTORY_DIR_NAME; @@ -212,10 +212,12 @@ pub fn workspace_skill_file(name: &str) -> PathBuf { workspace_skills_dir().join(name).join("SKILL.md") } -pub fn workspace_mcp_config_file() -> PathBuf { - workspace_config_dir() - .join(WORKSPACE_COYOTE_DIR_NAME) - .join(MCP_FILE_NAME) +pub fn workspace_mcp_config_file() -> 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()) } pub fn validate_skill_name(name: &str) -> Result<()> { @@ -689,6 +691,69 @@ mod tests { } } + mod workspace_mcp_resolution { + use super::*; + use serial_test::serial; + + 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 env_name = get_env_name("workspace_config_dir"); + let prev = env::var_os(&env_name); + unsafe { + env::set_var(&env_name, &root); + } + f(&root); + unsafe { + match prev { + Some(v) => env::set_var(&env_name, v), + None => env::remove_var(&env_name), + } + } + let _ = fs::remove_dir_all(&root); + } + + #[test] + #[serial] + fn returns_none_when_no_config_exists() { + with_workspace_dir(|_| { + assert_eq!(workspace_mcp_config_file(), 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"))); + }); + } + + #[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"))); + }); + } + + #[test] + #[serial] + fn prefers_mcp_json_when_both_exist() { + 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"))); + }); + } + } + #[test] fn sandbox_kit_override_reflects_env_var_state() { let env_name = get_env_name("sandbox_kit"); diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index a9e8e06..a95489d 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -215,49 +215,48 @@ impl McpRegistry { } let mut merged = mcp_servers_config; - if !app_config.no_workspace_mcp { - let ws_path = paths::workspace_mcp_config_file(); - if ws_path.try_exists().unwrap_or(false) { - match tokio::fs::read_to_string(&ws_path).await { - Ok(ws_content) if !ws_content.trim().is_empty() => { - match interpolate_secrets(&ws_content, vault) { - Ok((parsed, missing)) if missing.is_empty() => { - match serde_json::from_str::(&parsed) { - Ok(ws_config) => { - let mut loaded = Vec::new(); - for (name, spec) in ws_config.mcp_servers { - match spec.validate(&name) { - Ok(_) => { - loaded.push(name.clone()); - merged.mcp_servers.insert(name, spec); - } - Err(e) => warn!( - "Invalid workspace MCP server '{name}': {e}. Skipping." - ), + if !app_config.no_workspace_mcp + && let Some(ws_path) = paths::workspace_mcp_config_file() + { + match tokio::fs::read_to_string(&ws_path).await { + Ok(ws_content) if !ws_content.trim().is_empty() => { + match interpolate_secrets(&ws_content, vault) { + Ok((parsed, missing)) if missing.is_empty() => { + match serde_json::from_str::(&parsed) { + Ok(ws_config) => { + let mut loaded = Vec::new(); + for (name, spec) in ws_config.mcp_servers { + match spec.validate(&name) { + Ok(_) => { + loaded.push(name.clone()); + merged.mcp_servers.insert(name, spec); } - } - if !loaded.is_empty() { - eprintln!( - "Loading workspace MCP servers: {}", - loaded.join(", ") - ); + Err(e) => warn!( + "Invalid workspace MCP server '{name}': {e}. Skipping." + ), } } - Err(e) => warn!( - "Failed to parse workspace MCP config: {e}. Skipping." - ), + if !loaded.is_empty() { + eprintln!( + "Loading workspace MCP servers: {}", + loaded.join(", ") + ); + } + } + Err(e) => { + warn!("Failed to parse workspace MCP config: {e}. Skipping.") } } - Ok((_, missing)) => warn!( - "Workspace MCP config references missing vault secrets: {missing:?}. Skipping." - ), - Err(e) => { - warn!("Failed to process workspace MCP config: {e}. Skipping.") - } + } + Ok((_, missing)) => warn!( + "Workspace MCP config references missing vault secrets: {missing:?}. Skipping." + ), + Err(e) => { + warn!("Failed to process workspace MCP config: {e}. Skipping.") } } - _ => {} } + _ => {} } } registry.config = Some(merged);