use std::path::Path;
use anyhow::{Context, Result};
use mcpmesh_local_api::BackendSpec;
use crate::util::atomic_write;
pub(crate) fn read_config_for_rmw(path: &Path) -> Result<String> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => Err(e).with_context(|| format!("read config {}", path.display())),
}
}
const CONFIG_HEADER: &str = "# mcpmesh config — every table and key is documented in docs/config.md \
(https://github.com/counterpunchtech/mcpmesh/blob/main/docs/config.md)";
fn write_config_doc(path: &Path, doc: &toml::Table) -> Result<()> {
let rendered = toml::to_string_pretty(doc).context("serialize config.toml")?;
atomic_write(path, format!("{CONFIG_HEADER}\n{rendered}").as_bytes())
}
fn upsert_config_strings(path: &Path, table: &str, pairs: &[(&str, &str)]) -> Result<()> {
let existing = read_config_for_rmw(path)?;
let mut doc: toml::Table = toml::from_str(&existing)
.with_context(|| format!("parse existing config {}", path.display()))?;
let entry = doc
.entry(table.to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
let toml::Value::Table(entry) = entry else {
anyhow::bail!("[{table}] in {} is not a table", path.display());
};
for (k, v) in pairs {
entry.insert((*k).into(), toml::Value::String((*v).to_string()));
}
write_config_doc(path, &doc)
}
pub(crate) fn write_identity_pin(path: &Path, org_root_pk: &str, org_id: &str) -> Result<()> {
upsert_config_strings(
path,
"identity",
&[("org_root_pk", org_root_pk), ("org_id", org_id)],
)
}
pub(crate) fn write_identity_user_id(path: &Path, user_id: &str) -> Result<()> {
upsert_config_strings(path, "identity", &[("user_id", user_id)])
}
pub(crate) fn write_join_pin(
path: &Path,
org_id: &str,
org_root_pk: &str,
user_id: &str,
user_key: &str,
) -> Result<()> {
upsert_config_strings(
path,
"identity",
&[
("org_id", org_id),
("org_root_pk", org_root_pk),
("user_id", user_id),
("user_key", user_key),
],
)
}
pub(crate) fn write_roster_url(path: &Path, url: &str) -> Result<()> {
upsert_config_strings(path, "roster", &[("url", url)])
}
pub(crate) fn write_identity_nickname(path: &Path, nickname: &str) -> Result<()> {
upsert_config_strings(path, "identity", &[("nickname", nickname)])
}
pub(crate) fn write_service_to_config(
path: &Path,
name: &str,
backend: &BackendSpec,
allow: &[String],
) -> Result<()> {
let existing = read_config_for_rmw(path)?;
let mut doc: toml::Table = toml::from_str(&existing)
.with_context(|| format!("parse existing config {}", path.display()))?;
let services = doc
.entry("services".to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
let toml::Value::Table(services) = services else {
anyhow::bail!("[services] in {} is not a table", path.display());
};
let mut merged_allow: Vec<String> = services
.get(name)
.and_then(toml::Value::as_table)
.and_then(|t| t.get("allow"))
.and_then(toml::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
for a in allow {
if !merged_allow.iter().any(|existing| existing == a) {
merged_allow.push(a.clone());
}
}
let mut entry = toml::Table::new();
match backend {
BackendSpec::Run { cmd } => {
entry.insert(
"run".into(),
toml::Value::Array(cmd.iter().cloned().map(toml::Value::String).collect()),
);
}
BackendSpec::Socket { path } => {
entry.insert("socket".into(), toml::Value::String(path.clone()));
}
}
entry.insert(
"allow".into(),
toml::Value::Array(merged_allow.into_iter().map(toml::Value::String).collect()),
);
services.insert(name.to_string(), toml::Value::Table(entry));
write_config_doc(path, &doc)
}
pub(crate) fn append_allow_to_config(
path: &Path,
principal: &str,
services: &[String],
) -> Result<bool> {
let existing = read_config_for_rmw(path)?;
let mut doc: toml::Table = toml::from_str(&existing)
.with_context(|| format!("parse existing config {}", path.display()))?;
let Some(toml::Value::Table(services_tbl)) = doc.get_mut("services") else {
for svc in services {
tracing::warn!(service = %svc, "grant: service not in config; skipping allow-append");
}
return Ok(false);
};
let mut changed = false;
for svc in services {
let Some(entry) = services_tbl.get_mut(svc) else {
tracing::warn!(service = %svc, "grant: service not in config; skipping allow-append");
continue;
};
let toml::Value::Table(entry) = entry else {
anyhow::bail!("[services.{svc}] in {} is not a table", path.display());
};
let allow = entry
.entry("allow".to_string())
.or_insert_with(|| toml::Value::Array(Vec::new()));
let toml::Value::Array(allow_arr) = allow else {
anyhow::bail!(
"[services.{svc}].allow in {} is not an array",
path.display()
);
};
if allow_arr.iter().any(|v| v.as_str() == Some(principal)) {
continue;
}
allow_arr.push(toml::Value::String(principal.to_string()));
changed = true;
}
if changed {
write_config_doc(path, &doc)?;
}
Ok(changed)
}
pub(crate) fn remove_allow_from_config(path: &Path, principals: &[String]) -> Result<bool> {
let existing = read_config_for_rmw(path)?;
let mut doc: toml::Table = toml::from_str(&existing)
.with_context(|| format!("parse existing config {}", path.display()))?;
let Some(toml::Value::Table(services_tbl)) = doc.get_mut("services") else {
return Ok(false); };
let mut changed = false;
for (_, entry) in services_tbl.iter_mut() {
let toml::Value::Table(svc) = entry else {
continue; };
let Some(toml::Value::Array(allow)) = svc.get_mut("allow") else {
continue; };
let before = allow.len();
allow.retain(|v| {
v.as_str()
.is_none_or(|s| !principals.iter().any(|p| p == s))
});
if allow.len() != before {
changed = true;
}
}
if changed {
write_config_doc(path, &doc)?;
}
Ok(changed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[test]
fn managed_writes_carry_the_reference_header_exactly_once() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_service_to_config(
&path,
"kb",
&BackendSpec::Socket {
path: "/run/kb.sock".into(),
},
&[],
)
.unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(
text.starts_with("# mcpmesh config") && text.contains("docs/config.md"),
"the generated config opens with the reference header: {text}"
);
assert!(append_allow_to_config(&path, "bob", &["kb".to_string()]).unwrap());
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(
text.matches("# mcpmesh config").count(),
1,
"no duplicated header after a rewrite: {text}"
);
assert!(Config::load(&path).is_ok());
}
#[test]
fn write_join_pin_is_surgical() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[network]\nrelay_mode = \"disabled\"\n\n[identity]\nnickname = \"mydev\"\n\n\
[services.notes]\nrun = [\"notes-mcp\"]\nallow = [\"alice\"]\n",
)
.unwrap();
write_join_pin(
&path,
"acme",
"b64u:ANCHOR",
"alice",
"/home/alice/user.key",
)
.unwrap();
let doc: toml::Table = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let identity = doc["identity"].as_table().unwrap();
assert_eq!(identity["org_id"].as_str(), Some("acme"));
assert_eq!(identity["org_root_pk"].as_str(), Some("b64u:ANCHOR"));
assert_eq!(identity["user_id"].as_str(), Some("alice"));
assert_eq!(identity["user_key"].as_str(), Some("/home/alice/user.key"));
assert_eq!(identity["nickname"].as_str(), Some("mydev"));
assert_eq!(
doc["network"]["relay_mode"].as_str(),
Some("disabled"),
"unrelated [network] must be preserved"
);
assert!(
doc["services"]["notes"].is_table(),
"a pre-existing [services.*] must be preserved: {doc:?}"
);
}
#[test]
fn write_roster_url_upserts_and_preserves() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[network]\nrelay_mode = \"disabled\"\n\n[roster]\ngrace_period = \"7d\"\n",
)
.unwrap();
write_roster_url(&path, "https://acme.example/roster.json").unwrap();
let doc: toml::Table = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
doc["roster"]["url"].as_str(),
Some("https://acme.example/roster.json")
);
assert_eq!(doc["roster"]["grace_period"].as_str(), Some("7d"));
assert_eq!(doc["network"]["relay_mode"].as_str(), Some("disabled"));
let path2 = dir.path().join("c2.toml");
std::fs::write(&path2, "[identity]\nnickname = \"x\"\n").unwrap();
write_roster_url(&path2, "https://h/r").unwrap();
let doc2: toml::Table = toml::from_str(&std::fs::read_to_string(&path2).unwrap()).unwrap();
assert_eq!(doc2["roster"]["url"].as_str(), Some("https://h/r"));
assert_eq!(doc2["identity"]["nickname"].as_str(), Some("x"));
}
#[test]
fn write_identity_user_id_is_surgical() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[identity]\nnickname = \"dev\"\norg_id = \"acme\"\n").unwrap();
write_identity_user_id(&path, "b64u:USER").unwrap();
let doc: toml::Table = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(doc["identity"]["user_id"].as_str(), Some("b64u:USER"));
assert_eq!(doc["identity"]["nickname"].as_str(), Some("dev"));
assert_eq!(doc["identity"]["org_id"].as_str(), Some("acme"));
}
#[test]
fn upsert_bails_on_a_non_table_entry() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "identity = \"nope\"\n").unwrap();
let err = write_identity_user_id(&path, "x").unwrap_err();
assert!(
format!("{err:#}").contains("[identity]"),
"bail names the offending table: {err:#}"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"identity = \"nope\"\n"
);
}
#[test]
fn allow_config_append_and_remove_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[services.kb]\nsocket = \"/run/kb.sock\"\n").unwrap();
assert!(append_allow_to_config(&path, "bob", &["kb".to_string()]).unwrap());
let doc: toml::Table = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let allow = doc["services"]["kb"]["allow"].as_array().unwrap();
assert!(allow.iter().any(|v| v.as_str() == Some("bob")));
assert!(!append_allow_to_config(&path, "bob", &["kb".to_string()]).unwrap());
assert!(!append_allow_to_config(&path, "carol", &["ghost".to_string()]).unwrap());
assert!(remove_allow_from_config(&path, &["bob".to_string()]).unwrap());
let doc: toml::Table = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(
doc["services"]["kb"]["allow"]
.as_array()
.unwrap()
.is_empty()
);
assert!(!remove_allow_from_config(&path, &["nobody".to_string()]).unwrap());
let empty = dir.path().join("empty.toml");
std::fs::write(&empty, "[identity]\nnickname = \"x\"\n").unwrap();
assert!(!append_allow_to_config(&empty, "bob", &["kb".to_string()]).unwrap());
assert!(!remove_allow_from_config(&empty, &["bob".to_string()]).unwrap());
}
#[test]
fn reregistering_a_service_preserves_existing_allow_grants() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let socket = BackendSpec::Socket {
path: "/run/kb.sock".into(),
};
write_service_to_config(&path, "kb", &socket, &[]).unwrap();
append_allow_to_config(&path, "alice", &["kb".to_string()]).unwrap();
write_service_to_config(&path, "kb", &socket, &[]).unwrap();
let cfg = Config::load(&path).unwrap();
assert_eq!(
cfg.services.get("kb").unwrap().allow,
vec!["alice".to_string()],
"a kb re-registration must not wipe the pairing grant"
);
}
#[test]
fn reregistering_updates_backend_and_unions_incoming_allow() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_service_to_config(
&path,
"svc",
&BackendSpec::Socket {
path: "/a.sock".into(),
},
&["alice".to_string()],
)
.unwrap();
write_service_to_config(
&path,
"svc",
&BackendSpec::Socket {
path: "/b.sock".into(),
},
&["bob".to_string()],
)
.unwrap();
let cfg = Config::load(&path).unwrap();
let svc = cfg.services.get("svc").unwrap();
assert_eq!(svc.socket.as_deref(), Some("/b.sock"), "backend updates");
assert_eq!(
svc.allow,
vec!["alice".to_string(), "bob".to_string()],
"union: existing grant kept, incoming appended"
);
}
}