use std::collections::BTreeMap;
use std::path::Path;
use serde_json::{Map, Value};
use crate::error::AgentConfigError;
use crate::integration::{InstallReport, UninstallReport};
use crate::spec::{McpSpec, McpTransport};
use crate::util::{file_lock, fs_atomic, json_patch, ownership};
pub(crate) const ARRAY_KEY: &str = "mcp";
pub(crate) const NAME_FIELD: &str = "name";
pub(crate) fn is_installed(ledger_path: &Path, name: &str) -> Result<bool, AgentConfigError> {
ownership::contains(ledger_path, name)
}
pub(crate) fn install(
config_path: &Path,
ledger_path: &Path,
spec: &McpSpec,
) -> Result<InstallReport, AgentConfigError> {
file_lock::with_lock(config_path, || {
let mut report = InstallReport::default();
let mut root = json_patch::read_or_empty(config_path)?;
let in_config =
json_patch::contains_in_named_array(&root, &[ARRAY_KEY], NAME_FIELD, &spec.name);
ownership::require_owner(
ledger_path,
&spec.name,
&spec.owner_tag,
"mcp server",
in_config,
)?;
let value = build_array_entry(spec);
let changed = json_patch::upsert_named_array_entry(
&mut root,
&[ARRAY_KEY],
NAME_FIELD,
&spec.name,
value,
)?;
let prior_owner = ownership::owner_of(ledger_path, &spec.name)?;
let owner_changed = prior_owner.as_deref() != Some(spec.owner_tag.as_str());
let written_bytes: Option<Vec<u8>> = if changed {
let bytes = json_patch::to_pretty(&root);
let outcome = fs_atomic::write_atomic(config_path, &bytes, true)?;
if outcome.existed {
report.patched.push(outcome.path.clone());
} else {
report.created.push(outcome.path.clone());
}
if let Some(b) = outcome.backup {
report.backed_up.push(b);
}
Some(bytes)
} else {
None
};
if changed || owner_changed {
let hash = match written_bytes.as_deref() {
Some(b) => Some(ownership::content_hash(b)),
None => ownership::file_content_hash(config_path)?,
};
ownership::record_install(ledger_path, &spec.name, &spec.owner_tag, hash.as_deref())?;
}
if !changed && !owner_changed {
report.already_installed = true;
}
Ok(report)
})
}
pub(crate) fn uninstall(
config_path: &Path,
ledger_path: &Path,
name: &str,
owner_tag: &str,
kind: &'static str,
) -> Result<UninstallReport, AgentConfigError> {
if !config_path.exists() && !ledger_path.exists() {
return Ok(UninstallReport {
not_installed: true,
..UninstallReport::default()
});
}
file_lock::with_lock(config_path, || {
let mut report = UninstallReport::default();
let mut root = json_patch::read_or_empty(config_path)?;
let in_config = json_patch::contains_in_named_array(&root, &[ARRAY_KEY], NAME_FIELD, name);
let in_ledger = ownership::contains(ledger_path, name)?;
if !in_config && !in_ledger {
report.not_installed = true;
return Ok(report);
}
ownership::require_owner(ledger_path, name, owner_tag, kind, in_config)?;
if in_config {
let removed =
json_patch::remove_named_array_entry(&mut root, &[ARRAY_KEY], NAME_FIELD, name)?;
debug_assert!(removed);
let now_empty = root.as_object().map(Map::is_empty).unwrap_or(true);
let bytes = json_patch::to_pretty(&root);
if now_empty && fs_atomic::restore_backup_if_matches(config_path, &bytes)? {
report.restored.push(config_path.to_path_buf());
} else if now_empty {
fs_atomic::remove_if_exists(config_path)?;
report.removed.push(config_path.to_path_buf());
} else {
fs_atomic::write_atomic(config_path, &bytes, false)?;
report.patched.push(config_path.to_path_buf());
}
}
ownership::record_uninstall(ledger_path, name)?;
if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
report.not_installed = true;
}
Ok(report)
})
}
fn build_array_entry(spec: &McpSpec) -> Value {
let mut obj = Map::new();
obj.insert(NAME_FIELD.into(), Value::String(spec.name.clone()));
match &spec.transport {
McpTransport::Stdio { command, args, env } => {
obj.insert("type".into(), Value::String("local".into()));
obj.insert("command".into(), Value::String(command.clone()));
obj.insert(
"args".into(),
Value::Array(args.iter().cloned().map(Value::String).collect()),
);
if !env.is_empty() {
obj.insert("env".into(), env_value(env));
}
}
McpTransport::Http { url, headers } => {
obj.insert("type".into(), Value::String("remote".into()));
obj.insert("url".into(), Value::String(url.clone()));
if !headers.is_empty() {
obj.insert("headers".into(), env_value(headers));
}
}
McpTransport::Sse { url, headers } => {
obj.insert("type".into(), Value::String("sse".into()));
obj.insert("url".into(), Value::String(url.clone()));
if !headers.is_empty() {
obj.insert("headers".into(), env_value(headers));
}
}
}
Value::Object(obj)
}
fn env_value(map: &BTreeMap<String, String>) -> Value {
let mut obj = Map::new();
for (k, v) in map {
obj.insert(k.clone(), Value::String(v.clone()));
}
Value::Object(obj)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
fn paths(dir: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
(dir.join("config.json"), dir.join(".agent-config-mcp.json"))
}
fn stdio_spec(name: &str, owner: &str) -> McpSpec {
McpSpec::builder(name)
.owner(owner)
.stdio("npx", ["-y", "@example/server"])
.build()
}
#[test]
fn install_adds_array_entry_with_name() {
let dir = tempdir().unwrap();
let (cfg, led) = paths(dir.path());
install(&cfg, &led, &stdio_spec("github", "myapp")).unwrap();
let v: Value = serde_json::from_slice(&std::fs::read(&cfg).unwrap()).unwrap();
let arr = v["mcp"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["name"], json!("github"));
assert_eq!(arr[0]["command"], json!("npx"));
assert_eq!(arr[0]["type"], json!("local"));
}
#[test]
fn install_idempotent_with_same_owner() {
let dir = tempdir().unwrap();
let (cfg, led) = paths(dir.path());
let s = stdio_spec("github", "myapp");
install(&cfg, &led, &s).unwrap();
let r = install(&cfg, &led, &s).unwrap();
assert!(r.already_installed);
}
#[test]
fn install_coexists_with_user_servers() {
let dir = tempdir().unwrap();
let (cfg, led) = paths(dir.path());
std::fs::write(
&cfg,
r#"{ "mcp": [ { "name": "user", "command": "user-cmd" } ] }"#,
)
.unwrap();
install(&cfg, &led, &stdio_spec("github", "myapp")).unwrap();
let v: Value = serde_json::from_slice(&std::fs::read(&cfg).unwrap()).unwrap();
let arr = v["mcp"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert!(dir.path().join("config.json.bak").exists());
}
#[test]
fn install_refuses_other_owner() {
let dir = tempdir().unwrap();
let (cfg, led) = paths(dir.path());
install(&cfg, &led, &stdio_spec("github", "appA")).unwrap();
let err = install(&cfg, &led, &stdio_spec("github", "appB")).unwrap_err();
assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
}
#[test]
fn install_refuses_user_installed_same_name() {
let dir = tempdir().unwrap();
let (cfg, led) = paths(dir.path());
std::fs::write(
&cfg,
r#"{ "mcp": [ { "name": "github", "command": "user-cmd" } ] }"#,
)
.unwrap();
let err = install(&cfg, &led, &stdio_spec("github", "myapp")).unwrap_err();
assert!(matches!(
err,
AgentConfigError::NotOwnedByCaller { actual: None, .. }
));
}
#[test]
fn uninstall_refuses_other_owner() {
let dir = tempdir().unwrap();
let (cfg, led) = paths(dir.path());
install(&cfg, &led, &stdio_spec("github", "appA")).unwrap();
let err = uninstall(&cfg, &led, "github", "appB", "mcp server").unwrap_err();
assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
}
#[test]
fn uninstall_refuses_user_installed() {
let dir = tempdir().unwrap();
let (cfg, led) = paths(dir.path());
std::fs::write(
&cfg,
r#"{ "mcp": [ { "name": "user", "command": "user-cmd" } ] }"#,
)
.unwrap();
let err = uninstall(&cfg, &led, "user", "myapp", "mcp server").unwrap_err();
assert!(matches!(
err,
AgentConfigError::NotOwnedByCaller { actual: None, .. }
));
}
#[test]
fn uninstall_keeps_siblings() {
let dir = tempdir().unwrap();
let (cfg, led) = paths(dir.path());
install(&cfg, &led, &stdio_spec("alpha", "myapp")).unwrap();
install(&cfg, &led, &stdio_spec("beta", "myapp")).unwrap();
uninstall(&cfg, &led, "alpha", "myapp", "mcp server").unwrap();
let v: Value = serde_json::from_slice(&std::fs::read(&cfg).unwrap()).unwrap();
let arr = v["mcp"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["name"], json!("beta"));
}
}