From bc3cc10a7bed36e2c15661f68b33a8153564ef92 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 13 Jul 2026 17:12:34 -0600 Subject: [PATCH] feat: Support workspace-local skill definitions and MCP configurations --- src/cli/mod.rs | 3 +++ src/config/app_config.rs | 3 +++ src/config/memory.rs | 20 ++++++++-------- src/config/mod.rs | 2 +- src/config/paths.rs | 50 +++++++++++++++++++++++++++++----------- src/config/skill.rs | 6 ++++- src/main.rs | 6 ++++- src/mcp/mod.rs | 48 +++++++++++++++++++++++++++++++++++++- 8 files changed, 111 insertions(+), 27 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 40d525d..eddf571 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -195,6 +195,9 @@ pub struct Cli { /// Skip discovery and application of all sbx mixins (user and built-in) #[arg(long, requires = "sandbox")] pub no_mixins: bool, + /// Disable loading workspace MCP servers from .coyote/mcp.json + #[arg(long)] + pub no_workspace_mcp: bool, } impl Cli { diff --git a/src/config/app_config.rs b/src/config/app_config.rs index e892e37..6e94c4c 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -88,6 +88,7 @@ pub struct AppConfig { pub user_agent: Option, pub save_shell_history: bool, + pub no_workspace_mcp: bool, pub sync_models_url: Option, pub clients: Vec, @@ -162,6 +163,7 @@ impl Default for AppConfig { user_agent: None, save_shell_history: true, + no_workspace_mcp: false, sync_models_url: None, clients: vec![], @@ -238,6 +240,7 @@ impl AppConfig { user_agent: config.user_agent, save_shell_history: config.save_shell_history, + no_workspace_mcp: false, sync_models_url: config.sync_models_url, clients: config.clients, diff --git a/src/config/memory.rs b/src/config/memory.rs index 0598cab..6041898 100644 --- a/src/config/memory.rs +++ b/src/config/memory.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::config::{ GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, - WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths, + WORKSPACE_COYOTE_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths, }; pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000; @@ -27,7 +27,7 @@ pub enum WorkspaceMemory { pub fn discover_workspace_memory(start: &Path) -> Option { for dir in start.ancestors() { - let structured = dir.join(WORKSPACE_MEMORY_DIR_NAME).join(MEMORY_DIR_NAME); + let structured = dir.join(WORKSPACE_COYOTE_DIR_NAME).join(MEMORY_DIR_NAME); if structured.join(MEMORY_INDEX_FILE_NAME).exists() { return Some(WorkspaceMemory::Structured { workspace_root: dir.to_path_buf(), @@ -84,8 +84,8 @@ pub fn bootstrap_workspace_memory(git_root: &Path) -> Result { fn append_gitignore_entry(git_root: &Path) -> Result { let gitignore = git_root.join(GITIGNORE_FILE_NAME); - let entry = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}/"); - let entry_no_slash = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}"); + let entry = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}/"); + let entry_no_slash = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}"); let existing = fs::read_to_string(&gitignore).unwrap_or_default(); let already_present = existing.lines().any(|line| { @@ -347,7 +347,7 @@ mod tests { let root = temp_root("phase1"); let workspace = root.join("workspace"); let workspace_memory_dir = workspace - .join(WORKSPACE_MEMORY_DIR_NAME) + .join(WORKSPACE_COYOTE_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&workspace_memory_dir).unwrap(); fs::write( @@ -382,7 +382,7 @@ mod tests { let root = temp_root("prefer"); let workspace = root.join("ws"); let structured = workspace - .join(WORKSPACE_MEMORY_DIR_NAME) + .join(WORKSPACE_COYOTE_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&structured).unwrap(); fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "s").unwrap(); @@ -415,7 +415,7 @@ mod tests { let root = temp_root("indexes_only"); let workspace = root.join("ws"); let structured = workspace - .join(WORKSPACE_MEMORY_DIR_NAME) + .join(WORKSPACE_COYOTE_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&structured).unwrap(); fs::write( @@ -450,7 +450,7 @@ mod tests { let root = temp_root("drill_bodies"); let workspace = root.join("ws"); let structured = workspace - .join(WORKSPACE_MEMORY_DIR_NAME) + .join(WORKSPACE_COYOTE_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&structured).unwrap(); fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); @@ -485,7 +485,7 @@ mod tests { let root = temp_root("cap"); let workspace = root.join("ws"); let structured = workspace - .join(WORKSPACE_MEMORY_DIR_NAME) + .join(WORKSPACE_COYOTE_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&structured).unwrap(); fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); @@ -575,7 +575,7 @@ mod tests { let root = temp_root("walk_up"); let workspace = root.join("ws"); let mem_dir = workspace - .join(WORKSPACE_MEMORY_DIR_NAME) + .join(WORKSPACE_COYOTE_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&mem_dir).unwrap(); fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); diff --git a/src/config/mod.rs b/src/config/mod.rs index b1ea413..4839b2c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -143,7 +143,7 @@ const 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"; -const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote"; +const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote"; const SBX_KIT_DIR_NAME: &str = "sbx-kit"; const SBX_KIT_HASH_FILE: &str = "kit.sha256"; const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml"; diff --git a/src/config/paths.rs b/src/config/paths.rs index 742a92e..a61a361 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -5,7 +5,7 @@ use super::{ 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_MEMORY_DIR_NAME, + SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME, }; use crate::client::ProviderModels; use crate::config::REPL_HISTORY_DIR_NAME; @@ -118,7 +118,7 @@ pub fn global_tools_sbx_mixin_file() -> PathBuf { pub fn find_workspace_sbx_mixin(start: &Path) -> Option { for dir in start.ancestors() { let candidate = dir - .join(WORKSPACE_MEMORY_DIR_NAME) + .join(WORKSPACE_COYOTE_DIR_NAME) .join(SBX_MIXIN_FILE_NAME); if candidate.exists() { return Some(candidate); @@ -193,6 +193,24 @@ pub fn skill_file(name: &str) -> PathBuf { skill_dir(name).join("SKILL.md") } +pub fn workspace_skills_dir() -> PathBuf { + env::current_dir() + .unwrap_or_default() + .join(WORKSPACE_COYOTE_DIR_NAME) + .join(SKILLS_DIR_NAME) +} + +pub fn workspace_skill_file(name: &str) -> PathBuf { + workspace_skills_dir().join(name).join("SKILL.md") +} + +pub fn workspace_mcp_config_file() -> PathBuf { + env::current_dir() + .unwrap_or_default() + .join(WORKSPACE_COYOTE_DIR_NAME) + .join(MCP_FILE_NAME) +} + pub fn validate_skill_name(name: &str) -> Result<()> { if name.is_empty() { bail!("Skill name cannot be empty"); @@ -318,7 +336,7 @@ pub fn global_memory_index_path() -> PathBuf { pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf { workspace_root - .join(WORKSPACE_MEMORY_DIR_NAME) + .join(WORKSPACE_COYOTE_DIR_NAME) .join(MEMORY_DIR_NAME) } @@ -405,15 +423,21 @@ pub fn has_macro(name: &str) -> bool { pub fn list_skills() -> Vec { let mut names = Vec::new(); - if let Ok(rd) = read_dir(skills_dir()) { - for entry in rd.flatten() { - if let Ok(file_type) = entry.file_type() - && file_type.is_dir() - && let Some(name) = entry.file_name().to_str() - && entry.path().join("SKILL.md").is_file() - && validate_skill_name(name).is_ok() - { - names.push(name.to_string()); + let mut seen = HashSet::new(); + + for dir in [workspace_skills_dir(), skills_dir()] { + if let Ok(rd) = read_dir(dir) { + for entry in rd.flatten() { + if let Ok(file_type) = entry.file_type() + && file_type.is_dir() + && let Some(name) = entry.file_name().to_str() + && !seen.contains(name) + && entry.path().join("SKILL.md").is_file() + && validate_skill_name(name).is_ok() + { + seen.insert(name.to_string()); + names.push(name.to_string()); + } } } } @@ -423,7 +447,7 @@ pub fn list_skills() -> Vec { } pub fn has_skill(name: &str) -> bool { - skill_file(name).is_file() + workspace_skill_file(name).is_file() || skill_file(name).is_file() } pub fn local_models_override() -> Result> { diff --git a/src/config/skill.rs b/src/config/skill.rs index b0f3837..971c810 100644 --- a/src/config/skill.rs +++ b/src/config/skill.rs @@ -117,7 +117,11 @@ impl Skill { pub fn load(name: &str) -> Result { paths::validate_skill_name(name)?; - let path = paths::skill_file(name); + let path = if paths::workspace_skill_file(name).is_file() { + paths::workspace_skill_file(name) + } else { + paths::skill_file(name) + }; let content = read_to_string(&path) .with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?; Ok(Skill::new(name, &content)) diff --git a/src/main.rs b/src/main.rs index a8d7484..b4b652c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -187,7 +187,11 @@ async fn main() -> Result<()> { let abort_signal = create_abort_signal(); let start_mcp_servers = cli.agent.is_none() && cli.role.is_none(); let cfg = Config::load_with_interpolation(info_flag).await?; - let app_config: Arc = Arc::new(AppConfig::from_config(cfg)?); + let mut app_config = AppConfig::from_config(cfg)?; + if cli.no_workspace_mcp { + app_config.no_workspace_mcp = true; + } + let app_config: Arc = Arc::new(app_config); let app_state: Arc = Arc::new( AppState::init( app_config, diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 6d8bb49..a9e8e06 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -214,7 +214,53 @@ impl McpRegistry { spec.validate(name)?; } - registry.config = Some(mcp_servers_config); + 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 !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.") + } + } + } + _ => {} + } + } + } + registry.config = Some(merged); if start_mcp_servers && app_config.mcp_server_support { abortable_run_with_spinner(