feat: prefer ~/.config/coyote/mcp.json for the user-scope MCP config

The user-scope MCP config historically lived at
<config-dir>/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: <config-dir>/mcp.json (created there on first run)
- Historical <config-dir>/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.
This commit is contained in:
2026-08-26 15:34:20 -06:00
parent ebe7816600
commit 3762bbe09f
5 changed files with 94 additions and 3 deletions
+1 -1
View File
@@ -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 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) 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 mcp_servers: # Optional list of MCP servers that the agent utilizes
- github # Corresponds to the name of an MCP server in the `<coyote-config-dir>/functions/mcp.json` file - github # Corresponds to the name of an MCP server in the `<coyote-config-dir>/mcp.json` file
global_tools: # Optional list of additional global tools to enable for the agent; i.e. not tools specific to the agent global_tools: # Optional list of additional global tools to enable for the agent; i.e. not tools specific to the agent
- web_search - web_search
- fs - fs
+1 -1
View File
@@ -315,7 +315,7 @@ pub struct Cli {
/// URL for http/sse MCP server (used with --mcp-add) /// URL for http/sse MCP server (used with --mcp-add)
#[arg(long, value_name = "URL", help_heading = "MCP Servers")] #[arg(long, value_name = "URL", help_heading = "MCP Servers")]
pub url: Option<String>, pub url: Option<String>,
/// 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")] #[arg(long, value_enum, value_name = "SCOPE", help_heading = "MCP Servers")]
pub scope: Option<McpScopeArg>, pub scope: Option<McpScopeArg>,
/// Environment variable for stdio MCP server (repeatable): --env KEY=VALUE /// Environment variable for stdio MCP server (repeatable): --env KEY=VALUE
+86
View File
@@ -287,6 +287,18 @@ pub fn functions_bin_dir() -> PathBuf {
} }
pub fn mcp_config_file() -> 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) 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: 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-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] #[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");
+1
View File
@@ -1865,6 +1865,7 @@ impl RequestContext {
("rags_dir", display_path(&paths::rags_dir())), ("rags_dir", display_path(&paths::rags_dir())),
("macros_dir", display_path(&paths::macros_dir())), ("macros_dir", display_path(&paths::macros_dir())),
("functions_dir", display_path(&paths::functions_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())), ("sbx_kit_dir", display_path(&paths::sbx_kit_dir())),
("messages_file", display_path(&self.messages_file())), ("messages_file", display_path(&self.messages_file())),
]; ];
+5 -1
View File
@@ -517,7 +517,11 @@ impl Functions {
anyhow!("Failed to load embedded function file: {}", file.as_ref()) anyhow!("Failed to load embedded function file: {}", file.as_ref())
})?; })?;
let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; 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 let is_script = file_path
.extension() .extension()
.and_then(OsStr::to_str) .and_then(OsStr::to_str)