use std::path::PathBuf;
use serde::Serialize;
use crate::errors::{McpError, McpErrorKind};
use crate::install::{InstallOpts, Scope};
#[derive(Debug, Serialize)]
struct ContinueServer {
name: String,
command: String,
args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
env: Option<std::collections::BTreeMap<String, String>>,
}
pub fn write(opts: &InstallOpts) -> Result<(), McpError> {
let path = entry_path(opts)?;
let name = opts.entry_name();
if opts.uninstall {
if path.exists() {
std::fs::remove_file(&path).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("remove {}: {e}", path.display()),
)
})?;
}
return Ok(());
}
let server = ContinueServer {
name: name.clone(),
command: "tsafe-mcp".to_string(),
args: opts.server_args(),
env: env_map(),
};
let yaml = serde_yaml::to_string(&server).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("serialize Continue YAML: {e}"),
)
})?;
if opts.dry_run {
println!("# dry-run: would write {}", path.display());
println!("{yaml}");
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("create_dir_all {}: {e}", parent.display()),
)
})?;
}
let tmp = path.with_extension("yaml.tmp");
std::fs::write(&tmp, &yaml).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("write {}: {e}", tmp.display()),
)
})?;
std::fs::rename(&tmp, &path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
McpError::new(
McpErrorKind::InternalError,
format!("rename to {}: {e}", path.display()),
)
})?;
Ok(())
}
fn env_map() -> Option<std::collections::BTreeMap<String, String>> {
let sock = std::env::var("TSAFE_AGENT_SOCK")
.ok()
.filter(|v| !v.is_empty())?;
let mut m = std::collections::BTreeMap::new();
m.insert("TSAFE_AGENT_SOCK".to_string(), sock);
Some(m)
}
pub fn entry_path(opts: &InstallOpts) -> Result<PathBuf, McpError> {
let base = match &opts.scope {
Scope::Project { dir } => dir.clone(),
Scope::Global => directories::BaseDirs::new()
.map(|b| b.home_dir().to_path_buf())
.ok_or_else(|| {
McpError::new(
McpErrorKind::InternalError,
"could not resolve home directory",
)
})?,
};
Ok(base
.join(".continue")
.join("mcpServers")
.join(format!("{}.yaml", opts.entry_name())))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::install::Scope;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct ContinueFile {
name: String,
command: String,
args: Vec<String>,
}
#[test]
fn project_scope_writes_one_yaml_per_server() {
let tmp = tempfile::tempdir().unwrap();
let opts = InstallOpts {
profile: "demo".to_string(),
allowed_keys: vec!["demo/*".to_string()],
denied_keys: vec![],
contract: None,
allow_reveal: false,
name: Some("testsrv".to_string()),
scope: Scope::Project {
dir: tmp.path().to_path_buf(),
},
dry_run: false,
uninstall: false,
audit_source: None,
};
write(&opts).unwrap();
let path = tmp
.path()
.join(".continue")
.join("mcpServers")
.join("testsrv.yaml");
assert!(path.exists());
let yaml = std::fs::read_to_string(&path).unwrap();
let parsed: ContinueFile = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.name, "testsrv");
assert_eq!(parsed.command, "tsafe-mcp");
assert!(parsed.args.iter().any(|s| s == "--profile"));
}
#[test]
fn uninstall_removes_existing_file() {
let tmp = tempfile::tempdir().unwrap();
let opts = InstallOpts {
profile: "demo".to_string(),
allowed_keys: vec!["demo/*".to_string()],
denied_keys: vec![],
contract: None,
allow_reveal: false,
name: Some("byebye".to_string()),
scope: Scope::Project {
dir: tmp.path().to_path_buf(),
},
dry_run: false,
uninstall: false,
audit_source: None,
};
write(&opts).unwrap();
let path = entry_path(&opts).unwrap();
assert!(path.exists());
let uopts = InstallOpts {
uninstall: true,
..opts
};
write(&uopts).unwrap();
assert!(!path.exists());
}
}