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
@@ -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<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")]
pub scope: Option<McpScopeArg>,
/// 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 {
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: 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]
fn sandbox_kit_override_reflects_env_var_state() {
let env_name = get_env_name("sandbox_kit");
+1
View File
@@ -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())),
];
+5 -1
View File
@@ -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)