feat: Support claude-style hidden workspace MCP configuration files via .mcp.json
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-07-17 10:46:26 -06:00
parent 825f9f6bf5
commit 6958e9cba8
5 changed files with 113 additions and 46 deletions
+4 -2
View File
@@ -138,8 +138,10 @@ enabled_mcp_servers: null # Which MCP servers to enable by default.
# 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 from .coyote/mcp.json (default: false).
# When false (the default), Coyote merges .coyote/mcp.json from the current directory # 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 # into the global MCP registry at startup. If mcp.json is absent, Coyote falls back
# name collision. Set to true (or pass --no-workspace-mcp) to skip this entirely. # 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 ----
# Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation. # Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation.
+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 /// Disable loading workspace MCP servers from .coyote/mcp.json (or .coyote/.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
+1
View File
@@ -140,6 +140,7 @@ const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils"; const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh"; const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
const MCP_FILE_NAME: &str = "mcp.json"; const MCP_FILE_NAME: &str = "mcp.json";
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
const MEMORY_DIR_NAME: &str = "memory"; const MEMORY_DIR_NAME: &str = "memory";
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md"; const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md"; const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md";
+73 -8
View File
@@ -2,10 +2,10 @@ use super::role::Role;
use super::{ use super::{
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME, 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, 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, GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME,
MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME,
SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME,
SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
}; };
use crate::client::ProviderModels; use crate::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME; 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") workspace_skills_dir().join(name).join("SKILL.md")
} }
pub fn workspace_mcp_config_file() -> PathBuf { pub fn workspace_mcp_config_file() -> Option<PathBuf> {
workspace_config_dir() let dir = workspace_config_dir();
.join(WORKSPACE_COYOTE_DIR_NAME) [MCP_FILE_NAME, HIDDEN_MCP_FILE_NAME]
.join(MCP_FILE_NAME) .into_iter()
.map(|name| dir.join(name))
.find(|candidate| candidate.is_file())
} }
pub fn validate_skill_name(name: &str) -> Result<()> { 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: FnOnce(&Path)>(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] #[test]
fn sandbox_kit_override_reflects_env_var_state() { fn sandbox_kit_override_reflects_env_var_state() {
let env_name = get_env_name("sandbox_kit"); let env_name = get_env_name("sandbox_kit");
+34 -35
View File
@@ -215,49 +215,48 @@ impl McpRegistry {
} }
let mut merged = mcp_servers_config; let mut merged = mcp_servers_config;
if !app_config.no_workspace_mcp { if !app_config.no_workspace_mcp
let ws_path = paths::workspace_mcp_config_file(); && let Some(ws_path) = paths::workspace_mcp_config_file()
if ws_path.try_exists().unwrap_or(false) { {
match tokio::fs::read_to_string(&ws_path).await { match tokio::fs::read_to_string(&ws_path).await {
Ok(ws_content) if !ws_content.trim().is_empty() => { Ok(ws_content) if !ws_content.trim().is_empty() => {
match interpolate_secrets(&ws_content, vault) { match interpolate_secrets(&ws_content, vault) {
Ok((parsed, missing)) if missing.is_empty() => { Ok((parsed, missing)) if missing.is_empty() => {
match serde_json::from_str::<McpServersConfig>(&parsed) { match serde_json::from_str::<McpServersConfig>(&parsed) {
Ok(ws_config) => { Ok(ws_config) => {
let mut loaded = Vec::new(); let mut loaded = Vec::new();
for (name, spec) in ws_config.mcp_servers { for (name, spec) in ws_config.mcp_servers {
match spec.validate(&name) { match spec.validate(&name) {
Ok(_) => { Ok(_) => {
loaded.push(name.clone()); loaded.push(name.clone());
merged.mcp_servers.insert(name, spec); merged.mcp_servers.insert(name, spec);
}
Err(e) => warn!(
"Invalid workspace MCP server '{name}': {e}. Skipping."
),
} }
} Err(e) => warn!(
if !loaded.is_empty() { "Invalid workspace MCP server '{name}': {e}. Skipping."
eprintln!( ),
"Loading workspace MCP servers: {}",
loaded.join(", ")
);
} }
} }
Err(e) => warn!( if !loaded.is_empty() {
"Failed to parse workspace MCP config: {e}. Skipping." 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." Ok((_, missing)) => warn!(
), "Workspace MCP config references missing vault secrets: {missing:?}. Skipping."
Err(e) => { ),
warn!("Failed to process workspace MCP config: {e}. Skipping.") Err(e) => {
} warn!("Failed to process workspace MCP config: {e}. Skipping.")
} }
} }
_ => {}
} }
_ => {}
} }
} }
registry.config = Some(merged); registry.config = Some(merged);