From 3762bbe09fdbc1d459c5955381e533d093b31a17 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 25 Aug 2026 14:47:07 -0600 Subject: [PATCH] feat: prefer ~/.config/coyote/mcp.json for the user-scope MCP config The user-scope MCP config historically lived at /functions/mcp.json, a leftover from when MCP support was part of the llm-functions tooling. It now resolves through a single choke point with these semantics: - Preferred location: /mcp.json (created there on first run) - Historical /functions/mcp.json still honored when the preferred file does not exist, so existing installs are unchanged - If both exist, the preferred location wins --info/.info now reports the resolved location as mcp_config_file, and the --scope help text plus config.agent.example.yaml reference the new default. This also removes the asymmetry with the workspace scope, which already used .coyote/mcp.json directly. --- config.agent.example.yaml | 2 +- src/cli/mod.rs | 2 +- src/config/paths.rs | 86 +++++++++++++++++++++++++++++++++++ src/config/request_context.rs | 1 + src/function/mod.rs | 6 ++- 5 files changed, 94 insertions(+), 3 deletions(-) diff --git a/config.agent.example.yaml b/config.agent.example.yaml index 18c731c..a0852c1 100644 --- a/config.agent.example.yaml +++ b/config.agent.example.yaml @@ -48,7 +48,7 @@ summarization_model: null # Model to use for summarizing sub-agent output summarization_threshold: 4000 # Character threshold above which sub-agent output is summarized before returning to parent escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes) mcp_servers: # Optional list of MCP servers that the agent utilizes - - github # Corresponds to the name of an MCP server in the `/functions/mcp.json` file + - github # Corresponds to the name of an MCP server in the `/mcp.json` file global_tools: # Optional list of additional global tools to enable for the agent; i.e. not tools specific to the agent - web_search - fs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6c4e456..cc6df8a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -315,7 +315,7 @@ pub struct Cli { /// URL for http/sse MCP server (used with --mcp-add) #[arg(long, value_name = "URL", help_heading = "MCP Servers")] pub url: Option, - /// Scope for MCP config: user (~/.config/coyote/functions/mcp.json) or workspace (./.coyote/mcp.json). Default: user + /// Scope for MCP config: user (~/.config/coyote/mcp.json) or workspace (./.coyote/mcp.json). Default: user #[arg(long, value_enum, value_name = "SCOPE", help_heading = "MCP Servers")] pub scope: Option, /// Environment variable for stdio MCP server (repeatable): --env KEY=VALUE diff --git a/src/config/paths.rs b/src/config/paths.rs index 0e2154e..a8252f6 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -287,6 +287,18 @@ pub fn functions_bin_dir() -> PathBuf { } pub fn mcp_config_file() -> PathBuf { + let preferred = local_dir(MCP_FILE_NAME); + if preferred.exists() { + return preferred; + } + let legacy = legacy_mcp_config_file(); + if legacy.exists() { + return legacy; + } + preferred +} + +pub fn legacy_mcp_config_file() -> PathBuf { functions_dir().join(MCP_FILE_NAME) } @@ -837,6 +849,80 @@ mod tests { } } + mod user_mcp_resolution { + use super::*; + use serial_test::serial; + + fn with_config_dir(f: F) { + let unique = time::SystemTime::now() + .duration_since(time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("coyote-user-mcp-test-{unique}")); + fs::create_dir_all(root.join(FUNCTIONS_DIR_NAME)).unwrap(); + let config_env = get_env_name("config_dir"); + let functions_env = get_env_name("functions_dir"); + let prev_config = env::var_os(&config_env); + let prev_functions = env::var_os(&functions_env); + unsafe { + env::set_var(&config_env, &root); + env::set_var(&functions_env, root.join(FUNCTIONS_DIR_NAME)); + } + f(&root); + unsafe { + match prev_config { + Some(v) => env::set_var(&config_env, v), + None => env::remove_var(&config_env), + } + match prev_functions { + Some(v) => env::set_var(&functions_env, v), + None => env::remove_var(&functions_env), + } + } + let _ = fs::remove_dir_all(&root); + } + + #[test] + #[serial] + fn defaults_to_preferred_location_when_neither_exists() { + with_config_dir(|root| { + assert_eq!(mcp_config_file(), root.join(MCP_FILE_NAME)); + }); + } + + #[test] + #[serial] + fn falls_back_to_legacy_location_when_only_it_exists() { + with_config_dir(|root| { + let legacy = root.join(FUNCTIONS_DIR_NAME).join(MCP_FILE_NAME); + fs::write(&legacy, "{}").unwrap(); + assert_eq!(mcp_config_file(), legacy); + }); + } + + #[test] + #[serial] + fn prefers_new_location_when_both_exist() { + with_config_dir(|root| { + let preferred = root.join(MCP_FILE_NAME); + let legacy = root.join(FUNCTIONS_DIR_NAME).join(MCP_FILE_NAME); + fs::write(&preferred, "{}").unwrap(); + fs::write(&legacy, "{}").unwrap(); + assert_eq!(mcp_config_file(), preferred); + }); + } + + #[test] + #[serial] + fn uses_preferred_location_when_only_it_exists() { + with_config_dir(|root| { + let preferred = root.join(MCP_FILE_NAME); + fs::write(&preferred, "{}").unwrap(); + assert_eq!(mcp_config_file(), preferred); + }); + } + } + #[test] fn sandbox_kit_override_reflects_env_var_state() { let env_name = get_env_name("sandbox_kit"); diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 8fd9ab4..a4a2836 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -1865,6 +1865,7 @@ impl RequestContext { ("rags_dir", display_path(&paths::rags_dir())), ("macros_dir", display_path(&paths::macros_dir())), ("functions_dir", display_path(&paths::functions_dir())), + ("mcp_config_file", display_path(&paths::mcp_config_file())), ("sbx_kit_dir", display_path(&paths::sbx_kit_dir())), ("messages_file", display_path(&self.messages_file())), ]; diff --git a/src/function/mod.rs b/src/function/mod.rs index 38e72a9..19dd0b2 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -517,7 +517,11 @@ impl Functions { anyhow!("Failed to load embedded function file: {}", file.as_ref()) })?; let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; - let file_path = paths::functions_dir().join(file.as_ref()); + let file_path = if file.as_ref() == "mcp.json" { + paths::mcp_config_file() + } else { + paths::functions_dir().join(file.as_ref()) + }; let is_script = file_path .extension() .and_then(OsStr::to_str)