#[cfg(not(target_arch = "wasm32"))]
use fs4::fs_std::FileExt;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[cfg(not(target_arch = "wasm32"))]
use std::collections::HashSet;
#[cfg(not(target_arch = "wasm32"))]
use std::fs::{File, OpenOptions};
#[cfg(not(target_arch = "wasm32"))]
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
#[cfg(not(target_arch = "wasm32"))]
use toml_edit::{Array, DocumentMut, Item, Table};
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct McpConfig {
#[serde(default)]
pub servers: Vec<McpServerConfig>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum McpTransportKind {
Stdio,
StreamableHttp,
Sse,
}
impl McpTransportKind {
pub fn default_for_http() -> Self {
McpTransportKind::StreamableHttp
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct McpStdioConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct McpHttpConfig {
pub url: String,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub transport: Option<McpHttpTransport>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum McpHttpTransport {
#[default]
StreamableHttp,
Sse,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(untagged)]
pub enum McpTransportConfig {
Stdio(McpStdioConfig),
Http(McpHttpConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct McpServerConfig {
pub name: String,
#[serde(flatten)]
pub transport: McpTransportConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_timeout_secs: Option<u32>,
}
impl McpServerConfig {
pub fn stdio(
name: impl Into<String>,
command: impl Into<String>,
args: Vec<String>,
env: HashMap<String, String>,
) -> Self {
Self {
name: name.into(),
transport: McpTransportConfig::Stdio(McpStdioConfig {
command: command.into(),
args,
env,
}),
connect_timeout_secs: None,
}
}
pub fn streamable_http(
name: impl Into<String>,
url: impl Into<String>,
headers: HashMap<String, String>,
) -> Self {
Self {
name: name.into(),
transport: McpTransportConfig::Http(McpHttpConfig {
url: url.into(),
headers,
transport: None,
}),
connect_timeout_secs: None,
}
}
pub fn sse(
name: impl Into<String>,
url: impl Into<String>,
headers: HashMap<String, String>,
) -> Self {
Self {
name: name.into(),
transport: McpTransportConfig::Http(McpHttpConfig {
url: url.into(),
headers,
transport: Some(McpHttpTransport::Sse),
}),
connect_timeout_secs: None,
}
}
pub fn transport_kind(&self) -> McpTransportKind {
match &self.transport {
McpTransportConfig::Stdio(_) => McpTransportKind::Stdio,
McpTransportConfig::Http(http) => match http.transport.unwrap_or_default() {
McpHttpTransport::StreamableHttp => McpTransportKind::StreamableHttp,
McpHttpTransport::Sse => McpTransportKind::Sse,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpScope {
User,
Project,
}
#[derive(Debug, Clone)]
pub struct McpServerWithScope {
pub server: McpServerConfig,
pub scope: McpScope,
}
#[derive(Debug, Clone)]
pub struct McpConfigMutationAuthority {
pub scope: McpScope,
pub context_root: Option<PathBuf>,
pub user_config_root: Option<PathBuf>,
}
impl McpConfigMutationAuthority {
pub fn for_scope(
scope: McpScope,
context_root: Option<PathBuf>,
user_config_root: Option<PathBuf>,
) -> Self {
Self {
scope,
context_root,
user_config_root,
}
}
pub fn project(context_root: Option<PathBuf>, user_config_root: Option<PathBuf>) -> Self {
Self::for_scope(McpScope::Project, context_root, user_config_root)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn resolved_path(&self) -> Result<PathBuf, McpConfigError> {
match self.scope {
McpScope::Project => Ok(self
.context_root
.as_deref()
.map(project_mcp_path_in)
.or_else(project_mcp_path)
.ok_or(McpConfigError::PathUnavailable { scope: self.scope })?),
McpScope::User => Ok(self
.user_config_root
.as_deref()
.map(user_mcp_path_in)
.or_else(user_mcp_path)
.ok_or(McpConfigError::PathUnavailable { scope: self.scope })?),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone)]
pub struct McpConfigRollback {
path: PathBuf,
previous_bytes: Option<Vec<u8>>,
committed_bytes: Vec<u8>,
committed_revision: uuid::Uuid,
}
#[cfg(not(target_arch = "wasm32"))]
impl McpConfigRollback {
pub async fn rollback(self) -> Result<(), McpConfigError> {
let lock = acquire_mcp_config_lock(&self.path).await?;
let current = read_existing_bytes(&self.path).await?;
if lock.revision != Some(self.committed_revision)
|| current.as_deref() != Some(self.committed_bytes.as_slice())
{
return Err(McpConfigError::RollbackConflict {
path: self.path.display().to_string(),
});
}
let (lock, _) = reserve_mcp_config_revision(lock).await?;
let result = restore_mcp_file(&self.path, self.previous_bytes.as_deref()).await;
drop(lock);
result
}
}
#[derive(Debug, thiserror::Error)]
pub enum McpConfigError {
#[error("IO error: {0}")]
Io(String),
#[error("Parse error in {path}: {message}")]
Parse { path: String, message: String },
#[error("Server '{0}' already exists. Remove it first with: rkat mcp remove {0}")]
ServerExists(String),
#[error("Server '{0}' not found")]
ServerNotFound(String),
#[error("Server '{name}' exists in multiple scopes. Specify --scope: {scopes:?}")]
AmbiguousServer { name: String, scopes: Vec<McpScope> },
#[error("MCP config rollback conflict at {path}: config changed after this mutation")]
RollbackConflict { path: String },
#[error("MCP config revision sidecar is corrupt at {path}: {message}")]
RevisionCorrupt { path: String, message: String },
#[error("Could not determine MCP config path for {scope} scope")]
PathUnavailable { scope: McpScope },
#[error("Missing environment variable '{var}' referenced in {field}")]
MissingEnvVar { field: String, var: String },
#[error("Invalid environment variable reference in {field}: '{value}'")]
InvalidEnvVarSyntax { field: String, value: String },
}
#[cfg(not(target_arch = "wasm32"))]
impl McpConfig {
pub async fn load() -> Result<Self, McpConfigError> {
let user = user_mcp_path();
let project = project_mcp_path();
let user_cfg = read_mcp_file(user.as_deref()).await?;
let project_cfg = read_mcp_file(project.as_deref()).await?;
Ok(merge_project_over_user(user_cfg, project_cfg))
}
pub async fn load_from_roots(
context_root: Option<&Path>,
user_config_root: Option<&Path>,
) -> Result<Self, McpConfigError> {
let project_path = context_root.map(project_mcp_path_in);
let user_path = user_config_root.map(user_mcp_path_in);
Self::load_from_paths(user_path.as_deref(), project_path.as_deref()).await
}
pub async fn load_from_paths(
user_path: Option<&Path>,
project_path: Option<&Path>,
) -> Result<Self, McpConfigError> {
let user_cfg = read_mcp_file(user_path).await?;
let project_cfg = read_mcp_file(project_path).await?;
Ok(merge_project_over_user(user_cfg, project_cfg))
}
pub async fn load_with_scopes() -> Result<Vec<McpServerWithScope>, McpConfigError> {
let user_path = user_mcp_path();
let project_path = project_mcp_path();
let user_cfg = read_mcp_file(user_path.as_deref()).await?;
let project_cfg = read_mcp_file(project_path.as_deref()).await?;
let mut seen: HashSet<String> = HashSet::new();
let mut result: Vec<McpServerWithScope> = Vec::new();
for server in project_cfg.servers {
if seen.insert(server.name.clone()) {
result.push(McpServerWithScope {
server,
scope: McpScope::Project,
});
}
}
for server in user_cfg.servers {
if seen.insert(server.name.clone()) {
result.push(McpServerWithScope {
server,
scope: McpScope::User,
});
}
}
Ok(result)
}
pub async fn load_with_scopes_from_roots(
context_root: Option<&Path>,
user_config_root: Option<&Path>,
) -> Result<Vec<McpServerWithScope>, McpConfigError> {
let user_path = user_config_root.map(user_mcp_path_in);
let project_path = context_root.map(project_mcp_path_in);
let user_cfg = read_mcp_file(user_path.as_deref()).await?;
let project_cfg = read_mcp_file(project_path.as_deref()).await?;
let mut seen: HashSet<String> = HashSet::new();
let mut result: Vec<McpServerWithScope> = Vec::new();
for server in project_cfg.servers {
if seen.insert(server.name.clone()) {
result.push(McpServerWithScope {
server,
scope: McpScope::Project,
});
}
}
for server in user_cfg.servers {
if seen.insert(server.name.clone()) {
result.push(McpServerWithScope {
server,
scope: McpScope::User,
});
}
}
Ok(result)
}
pub async fn load_scope(scope: McpScope) -> Result<Self, McpConfigError> {
let path = match scope {
McpScope::User => user_mcp_path(),
McpScope::Project => project_mcp_path(),
};
read_mcp_file(path.as_deref()).await
}
pub async fn load_scope_from_roots(
scope: McpScope,
context_root: Option<&Path>,
user_config_root: Option<&Path>,
) -> Result<Self, McpConfigError> {
let path = match scope {
McpScope::User => user_config_root.map(user_mcp_path_in),
McpScope::Project => context_root.map(project_mcp_path_in),
};
read_mcp_file(path.as_deref()).await
}
pub async fn server_exists(name: &str, scope: McpScope) -> Result<bool, McpConfigError> {
let config = Self::load_scope(scope).await?;
Ok(config.servers.iter().any(|s| s.name == name))
}
pub async fn server_exists_from_roots(
name: &str,
scope: McpScope,
context_root: Option<&Path>,
user_config_root: Option<&Path>,
) -> Result<bool, McpConfigError> {
let authority = McpConfigMutationAuthority::for_scope(
scope,
context_root.map(Path::to_path_buf),
user_config_root.map(Path::to_path_buf),
);
document_contains_server(&authority.resolved_path()?, name).await
}
pub async fn find_server_scopes(name: &str) -> Result<Vec<McpScope>, McpConfigError> {
let mut scopes = Vec::new();
if Self::server_exists(name, McpScope::Project).await? {
scopes.push(McpScope::Project);
}
if Self::server_exists(name, McpScope::User).await? {
scopes.push(McpScope::User);
}
Ok(scopes)
}
pub async fn find_server_scopes_from_roots(
name: &str,
context_root: Option<&Path>,
user_config_root: Option<&Path>,
) -> Result<Vec<McpScope>, McpConfigError> {
let mut scopes = Vec::new();
if Self::server_exists_from_roots(name, McpScope::Project, context_root, user_config_root)
.await?
{
scopes.push(McpScope::Project);
}
if Self::server_exists_from_roots(name, McpScope::User, context_root, user_config_root)
.await?
{
scopes.push(McpScope::User);
}
Ok(scopes)
}
pub async fn persist_add_with_rollback(
authority: &McpConfigMutationAuthority,
server: McpServerConfig,
) -> Result<McpConfigRollback, McpConfigError> {
let path = authority.resolved_path()?;
let lock = acquire_mcp_config_lock(&path).await?;
let previous = read_existing_bytes(&path).await?;
let mut document = read_mcp_document(&path).await?;
let servers = servers_array_mut(&mut document, &path)?;
for existing in servers.iter() {
if document_server_name(existing, &path)? == server.name.as_str() {
return Err(McpConfigError::ServerExists(server.name));
}
}
servers.push(server_table(&server));
let (lock, committed_revision) = reserve_mcp_config_revision(lock).await?;
let committed_bytes = write_mcp_document_atomic(&path, &document).await?;
drop(lock);
Ok(McpConfigRollback {
path,
previous_bytes: previous,
committed_bytes,
committed_revision,
})
}
pub async fn persist_remove_with_rollback(
authority: &McpConfigMutationAuthority,
server_name: &str,
) -> Result<McpConfigRollback, McpConfigError> {
let path = authority.resolved_path()?;
let lock = acquire_mcp_config_lock(&path).await?;
let previous = read_existing_bytes(&path).await?;
let mut document = read_mcp_document(&path).await?;
let servers = servers_array_mut(&mut document, &path)?;
for server in servers.iter() {
document_server_name(server, &path)?;
}
let initial_len = servers.len();
servers.retain(|server| {
server.get("name").and_then(|value| value.as_str()) != Some(server_name)
});
if servers.len() == initial_len {
return Err(McpConfigError::ServerNotFound(server_name.to_string()));
}
let (lock, committed_revision) = reserve_mcp_config_revision(lock).await?;
let committed_bytes = write_mcp_document_atomic(&path, &document).await?;
drop(lock);
Ok(McpConfigRollback {
path,
previous_bytes: previous,
committed_bytes,
committed_revision,
})
}
}
#[cfg(not(target_arch = "wasm32"))]
struct McpConfigFileLock {
file: File,
revision: Option<uuid::Uuid>,
valid_revision_bytes: u64,
}
#[cfg(not(target_arch = "wasm32"))]
impl Drop for McpConfigFileLock {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn mcp_config_lock_path(path: &Path) -> PathBuf {
path.with_extension(format!(
"{}.lock",
path.extension()
.and_then(|extension| extension.to_str())
.unwrap_or("toml")
))
}
#[cfg(not(target_arch = "wasm32"))]
async fn acquire_mcp_config_lock(path: &Path) -> Result<McpConfigFileLock, McpConfigError> {
let lock_path = mcp_config_lock_path(path);
tokio::task::spawn_blocking(move || -> Result<McpConfigFileLock, McpConfigError> {
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
McpConfigError::Io(format!("create MCP config lock directory failed: {error}"))
})?;
}
let mut file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.map_err(|error| McpConfigError::Io(format!("open MCP config lock failed: {error}")))?;
FileExt::lock_exclusive(&file)
.map_err(|error| McpConfigError::Io(format!("MCP config lock failed: {error}")))?;
let (revision, valid_revision_bytes) = read_mcp_config_revision(&mut file, &lock_path)?;
Ok(McpConfigFileLock {
file,
revision,
valid_revision_bytes,
})
})
.await
.map_err(|error| McpConfigError::Io(format!("MCP config lock task failed: {error}")))?
}
#[cfg(not(target_arch = "wasm32"))]
const MCP_CONFIG_REVISION_PREFIX: &str = "rkat-mcp-config-revision-v1 ";
#[cfg(not(target_arch = "wasm32"))]
fn read_mcp_config_revision(
file: &mut File,
lock_path: &Path,
) -> Result<(Option<uuid::Uuid>, u64), McpConfigError> {
file.seek(SeekFrom::Start(0))
.and_then(|_| {
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).map(|_| bytes)
})
.map_err(|error| McpConfigError::Io(format!("read MCP config revision failed: {error}")))
.and_then(|bytes| {
let valid_len = bytes
.iter()
.rposition(|byte| *byte == b'\n')
.map_or(0, |index| index.saturating_add(1));
let mut revision = None;
for raw_line in bytes[..valid_len].split(|byte| *byte == b'\n') {
if raw_line.is_empty() {
continue;
}
let line = std::str::from_utf8(raw_line).map_err(|error| {
McpConfigError::RevisionCorrupt {
path: lock_path.display().to_string(),
message: format!("record is not UTF-8: {error}"),
}
})?;
let value = line
.strip_prefix(MCP_CONFIG_REVISION_PREFIX)
.ok_or_else(|| McpConfigError::RevisionCorrupt {
path: lock_path.display().to_string(),
message: "record has an unknown format".to_string(),
})?;
revision = Some(uuid::Uuid::parse_str(value).map_err(|error| {
McpConfigError::RevisionCorrupt {
path: lock_path.display().to_string(),
message: format!("record has an invalid UUID: {error}"),
}
})?);
}
let valid_revision_bytes =
u64::try_from(valid_len).map_err(|_| McpConfigError::RevisionCorrupt {
path: lock_path.display().to_string(),
message: "revision log length exceeds the supported range".to_string(),
})?;
Ok((revision, valid_revision_bytes))
})
}
#[cfg(not(target_arch = "wasm32"))]
async fn reserve_mcp_config_revision(
lock: McpConfigFileLock,
) -> Result<(McpConfigFileLock, uuid::Uuid), McpConfigError> {
tokio::task::spawn_blocking(move || {
let mut lock = lock;
let revision = fresh_mcp_config_revision(lock.revision)?;
lock.file
.set_len(lock.valid_revision_bytes)
.and_then(|()| lock.file.seek(SeekFrom::End(0)).map(|_| ()))
.and_then(|()| writeln!(lock.file, "{MCP_CONFIG_REVISION_PREFIX}{revision}"))
.and_then(|()| lock.file.sync_all())
.map_err(|error| {
McpConfigError::Io(format!("reserve MCP config revision failed: {error}"))
})?;
lock.valid_revision_bytes = lock.file.stream_position().map_err(|error| {
McpConfigError::Io(format!("read MCP config revision position failed: {error}"))
})?;
lock.revision = Some(revision);
Ok((lock, revision))
})
.await
.map_err(|error| McpConfigError::Io(format!("MCP config revision task failed: {error}")))?
}
#[cfg(not(target_arch = "wasm32"))]
fn fresh_mcp_config_revision(current: Option<uuid::Uuid>) -> Result<uuid::Uuid, McpConfigError> {
let mut bytes = [0_u8; 16];
getrandom::fill(&mut bytes).map_err(|error| {
McpConfigError::Io(format!(
"generate MCP config revision entropy failed: {error}"
))
})?;
let mut revision = uuid::Builder::from_random_bytes(bytes).into_uuid();
if Some(revision) == current {
bytes[15] ^= 1;
revision = uuid::Builder::from_random_bytes(bytes).into_uuid();
}
Ok(revision)
}
#[cfg(not(target_arch = "wasm32"))]
async fn read_mcp_file(path: Option<&Path>) -> Result<McpConfig, McpConfigError> {
let parsed = read_mcp_file_raw(path).await?;
expand_env_in_config(parsed)
}
#[cfg(not(target_arch = "wasm32"))]
async fn read_mcp_file_raw(path: Option<&Path>) -> Result<McpConfig, McpConfigError> {
let Some(path) = path else {
return Ok(McpConfig::default());
};
if !tokio::fs::try_exists(path)
.await
.map_err(|e| McpConfigError::Io(e.to_string()))?
{
return Ok(McpConfig::default());
}
let contents = tokio::fs::read_to_string(path)
.await
.map_err(|e| McpConfigError::Io(e.to_string()))?;
let parsed: McpConfig = toml::from_str(&contents).map_err(|e| McpConfigError::Parse {
path: path.display().to_string(),
message: e.to_string(),
})?;
Ok(parsed)
}
#[cfg(not(target_arch = "wasm32"))]
async fn read_existing_bytes(path: &Path) -> Result<Option<Vec<u8>>, McpConfigError> {
if !tokio::fs::try_exists(path)
.await
.map_err(|err| McpConfigError::Io(err.to_string()))?
{
return Ok(None);
}
tokio::fs::read(path)
.await
.map(Some)
.map_err(|err| McpConfigError::Io(err.to_string()))
}
#[cfg(not(target_arch = "wasm32"))]
async fn read_mcp_document(path: &Path) -> Result<DocumentMut, McpConfigError> {
if !tokio::fs::try_exists(path)
.await
.map_err(|err| McpConfigError::Io(err.to_string()))?
{
return Ok(DocumentMut::new());
}
let contents = tokio::fs::read_to_string(path)
.await
.map_err(|err| McpConfigError::Io(err.to_string()))?;
contents
.parse::<DocumentMut>()
.map_err(|err| McpConfigError::Parse {
path: path.display().to_string(),
message: err.to_string(),
})
}
#[cfg(not(target_arch = "wasm32"))]
async fn document_contains_server(path: &Path, name: &str) -> Result<bool, McpConfigError> {
let document = read_mcp_document(path).await?;
let Some(servers) = document.get("servers") else {
return Ok(false);
};
let servers = servers
.as_array_of_tables()
.ok_or_else(|| McpConfigError::Parse {
path: path.display().to_string(),
message: "'servers' must be an array of tables".to_string(),
})?;
for server in servers {
if document_server_name(server, path)? == name {
return Ok(true);
}
}
Ok(false)
}
#[cfg(not(target_arch = "wasm32"))]
fn document_server_name<'a>(server: &'a Table, path: &Path) -> Result<&'a str, McpConfigError> {
server
.get("name")
.and_then(|value| value.as_str())
.ok_or_else(|| McpConfigError::Parse {
path: path.display().to_string(),
message: "each [[servers]] table must contain a string 'name'".to_string(),
})
}
#[cfg(not(target_arch = "wasm32"))]
fn servers_array_mut<'a>(
document: &'a mut DocumentMut,
path: &Path,
) -> Result<&'a mut toml_edit::ArrayOfTables, McpConfigError> {
if !document.contains_key("servers") {
document["servers"] = Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
}
document["servers"]
.as_array_of_tables_mut()
.ok_or_else(|| McpConfigError::Parse {
path: path.display().to_string(),
message: "'servers' must be an array of tables".to_string(),
})
}
#[cfg(not(target_arch = "wasm32"))]
fn string_array(values: &[String]) -> Array {
let mut array = Array::new();
for value in values {
array.push(value.as_str());
}
array
}
#[cfg(not(target_arch = "wasm32"))]
fn string_map(values: &HashMap<String, String>) -> toml_edit::InlineTable {
let mut table = toml_edit::InlineTable::new();
let mut entries = values.iter().collect::<Vec<_>>();
entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
for (key, value) in entries {
table.insert(key, value.as_str().into());
}
table
}
#[cfg(not(target_arch = "wasm32"))]
fn server_table(server: &McpServerConfig) -> Table {
let mut table = Table::new();
table["name"] = toml_edit::value(&server.name);
match &server.transport {
McpTransportConfig::Stdio(stdio) => {
table["command"] = toml_edit::value(&stdio.command);
if !stdio.args.is_empty() {
table["args"] = toml_edit::value(string_array(&stdio.args));
}
if !stdio.env.is_empty() {
table["env"] = toml_edit::value(string_map(&stdio.env));
}
}
McpTransportConfig::Http(http) => {
table["url"] = toml_edit::value(&http.url);
if !http.headers.is_empty() {
table["headers"] = toml_edit::value(string_map(&http.headers));
}
if let Some(transport) = http.transport {
table["transport"] = toml_edit::value(match transport {
McpHttpTransport::StreamableHttp => "streamable-http",
McpHttpTransport::Sse => "sse",
});
}
}
}
if let Some(timeout) = server.connect_timeout_secs {
table["connect_timeout_secs"] = toml_edit::value(i64::from(timeout));
}
table
}
#[cfg(not(target_arch = "wasm32"))]
async fn write_mcp_document_atomic(
path: &Path,
document: &DocumentMut,
) -> Result<Vec<u8>, McpConfigError> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|err| McpConfigError::Io(err.to_string()))?;
}
let contents = document.to_string().into_bytes();
let tmp_path = path.with_extension(format!(
"{}.tmp",
path.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("toml")
));
tokio::fs::write(&tmp_path, &contents)
.await
.map_err(|err| McpConfigError::Io(err.to_string()))?;
tokio::fs::rename(&tmp_path, path)
.await
.map_err(|err| McpConfigError::Io(err.to_string()))?;
Ok(contents)
}
#[cfg(not(target_arch = "wasm32"))]
async fn restore_mcp_file(path: &Path, previous: Option<&[u8]>) -> Result<(), McpConfigError> {
match previous {
Some(bytes) => {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|err| McpConfigError::Io(err.to_string()))?;
}
let tmp_path = path.with_extension(format!(
"{}.rollback.tmp",
path.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("toml")
));
tokio::fs::write(&tmp_path, bytes)
.await
.map_err(|err| McpConfigError::Io(err.to_string()))?;
tokio::fs::rename(&tmp_path, path)
.await
.map_err(|err| McpConfigError::Io(err.to_string()))?;
}
None => {
if tokio::fs::try_exists(path)
.await
.map_err(|err| McpConfigError::Io(err.to_string()))?
{
tokio::fs::remove_file(path)
.await
.map_err(|err| McpConfigError::Io(err.to_string()))?;
}
}
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn merge_project_over_user(user: McpConfig, project: McpConfig) -> McpConfig {
let mut seen: HashSet<String> = HashSet::new();
let mut merged: Vec<McpServerConfig> = Vec::new();
for server in project.servers {
if seen.insert(server.name.clone()) {
merged.push(server);
}
}
for server in user.servers {
if seen.insert(server.name.clone()) {
merged.push(server);
}
}
McpConfig { servers: merged }
}
#[cfg(not(target_arch = "wasm32"))]
fn expand_env_in_config(config: McpConfig) -> Result<McpConfig, McpConfigError> {
expand_env_in_config_with(config, &|key| std::env::var(key).ok())
}
#[cfg(not(target_arch = "wasm32"))]
fn expand_env_in_config_with<F>(config: McpConfig, env: &F) -> Result<McpConfig, McpConfigError>
where
F: Fn(&str) -> Option<String>,
{
let mut servers = Vec::with_capacity(config.servers.len());
for server in config.servers {
servers.push(expand_env_in_server_with(server, env)?);
}
Ok(McpConfig { servers })
}
#[cfg(not(target_arch = "wasm32"))]
fn expand_env_in_server_with<F>(
server: McpServerConfig,
env: &F,
) -> Result<McpServerConfig, McpConfigError>
where
F: Fn(&str) -> Option<String>,
{
let transport = match server.transport {
McpTransportConfig::Stdio(stdio) => {
let command = expand_env_in_string_with(&stdio.command, "servers[].command", env)?;
let args = stdio
.args
.into_iter()
.map(|arg| expand_env_in_string_with(&arg, "servers[].args", env))
.collect::<Result<Vec<_>, _>>()?;
let env = expand_env_in_map_with(stdio.env, "servers[].env", env)?;
McpTransportConfig::Stdio(McpStdioConfig { command, args, env })
}
McpTransportConfig::Http(http) => {
let url = expand_env_in_string_with(&http.url, "servers[].url", env)?;
let headers = expand_env_in_map_with(http.headers, "servers[].headers", env)?;
McpTransportConfig::Http(McpHttpConfig {
url,
headers,
transport: http.transport,
})
}
};
Ok(McpServerConfig {
name: server.name,
transport,
connect_timeout_secs: server.connect_timeout_secs,
})
}
#[cfg(not(target_arch = "wasm32"))]
fn expand_env_in_map_with<F>(
map: HashMap<String, String>,
field: &str,
env: &F,
) -> Result<HashMap<String, String>, McpConfigError>
where
F: Fn(&str) -> Option<String>,
{
let mut expanded = HashMap::with_capacity(map.len());
for (key, value) in map {
let value = expand_env_in_string_with(&value, field, env)?;
expanded.insert(key, value);
}
Ok(expanded)
}
#[cfg(not(target_arch = "wasm32"))]
fn expand_env_in_string_with<F>(value: &str, field: &str, env: &F) -> Result<String, McpConfigError>
where
F: Fn(&str) -> Option<String>,
{
let mut output = String::with_capacity(value.len());
let mut remaining = value;
while let Some(start) = remaining.find("${") {
output.push_str(&remaining[..start]);
let after = &remaining[start + 2..];
let Some(end) = after.find('}') else {
return Err(McpConfigError::InvalidEnvVarSyntax {
field: field.to_string(),
value: value.to_string(),
});
};
let var_name = &after[..end];
if var_name.is_empty() {
return Err(McpConfigError::InvalidEnvVarSyntax {
field: field.to_string(),
value: value.to_string(),
});
}
let var_value = env(var_name).ok_or_else(|| McpConfigError::MissingEnvVar {
field: field.to_string(),
var: var_name.to_string(),
})?;
output.push_str(&var_value);
remaining = &after[end + 1..];
}
output.push_str(remaining);
Ok(output)
}
pub fn user_mcp_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".rkat/mcp.toml"))
}
pub fn user_mcp_path_in(root: &Path) -> PathBuf {
root.join(".rkat/mcp.toml")
}
pub fn user_mcp_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".rkat"))
}
pub fn find_project_mcp() -> Option<PathBuf> {
let cwd = std::env::current_dir().ok()?;
find_project_mcp_in(&cwd)
}
pub fn find_project_mcp_in(dir: &Path) -> Option<PathBuf> {
let candidate = dir.join(".rkat/mcp.toml");
if candidate.exists() {
Some(candidate)
} else {
None
}
}
pub fn project_mcp_path() -> Option<PathBuf> {
std::env::current_dir()
.ok()
.map(|cwd| cwd.join(".rkat/mcp.toml"))
}
pub fn project_mcp_path_in(root: &Path) -> PathBuf {
root.join(".rkat/mcp.toml")
}
pub fn project_mcp_dir() -> Option<PathBuf> {
std::env::current_dir().ok().map(|cwd| cwd.join(".rkat"))
}
impl std::fmt::Display for McpScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
McpScope::User => write!(f, "user"),
McpScope::Project => write!(f, "project"),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_empty_config_loads() {
let config = McpConfig::load_from_paths(None, None).await.unwrap();
assert!(config.servers.is_empty());
}
#[test]
fn test_parse_mcp_toml() {
let toml = r#"
[[servers]]
name = "test-server"
command = "npx"
args = ["-y", "@test/mcp-server"]
env = { API_KEY = "secret" }
[[servers]]
name = "remote-server"
url = "https://example.com/mcp"
headers = { Authorization = "Bearer token" }
"#;
let config: McpConfig = toml::from_str(toml).unwrap();
assert_eq!(config.servers.len(), 2);
assert_eq!(config.servers[0].name, "test-server");
match &config.servers[0].transport {
McpTransportConfig::Stdio(stdio) => {
assert_eq!(stdio.command, "npx");
assert_eq!(stdio.args, vec!["-y", "@test/mcp-server"]);
assert_eq!(stdio.env.get("API_KEY"), Some(&"secret".to_string()));
}
McpTransportConfig::Http(_) => unreachable!("Expected stdio transport"),
}
assert_eq!(config.servers[1].name, "remote-server");
match &config.servers[1].transport {
McpTransportConfig::Http(http) => {
assert_eq!(http.url, "https://example.com/mcp");
assert_eq!(
http.headers.get("Authorization"),
Some(&"Bearer token".to_string())
);
}
McpTransportConfig::Stdio(_) => unreachable!("Expected http transport"),
}
}
#[test]
fn test_merge_project_over_user() {
let user = McpConfig {
servers: vec![
McpServerConfig::stdio("shared", "user-cmd", vec![], HashMap::new()),
McpServerConfig::stdio("user-only", "user-only-cmd", vec![], HashMap::new()),
],
};
let project = McpConfig {
servers: vec![
McpServerConfig::stdio("shared", "project-cmd", vec![], HashMap::new()),
McpServerConfig::stdio("project-only", "project-only-cmd", vec![], HashMap::new()),
],
};
let merged = merge_project_over_user(user, project);
assert_eq!(merged.servers.len(), 3);
assert_eq!(merged.servers[0].name, "shared");
match &merged.servers[0].transport {
McpTransportConfig::Stdio(stdio) => {
assert_eq!(stdio.command, "project-cmd"); }
McpTransportConfig::Http(_) => unreachable!("Expected stdio transport"),
}
assert_eq!(merged.servers[1].name, "project-only");
assert_eq!(merged.servers[2].name, "user-only");
}
#[tokio::test]
async fn test_load_from_files() {
let temp = TempDir::new().unwrap();
let user_dir = temp.path().join("user");
tokio::fs::create_dir_all(&user_dir).await.unwrap();
let user_file = user_dir.join("mcp.toml");
tokio::fs::write(
&user_file,
r#"
[[servers]]
name = "user-server"
command = "user-cmd"
"#,
)
.await
.unwrap();
let project_dir = temp.path().join("project");
tokio::fs::create_dir_all(&project_dir).await.unwrap();
let project_file = project_dir.join("mcp.toml");
tokio::fs::write(
&project_file,
r#"
[[servers]]
name = "project-server"
command = "project-cmd"
"#,
)
.await
.unwrap();
let config = McpConfig::load_from_paths(Some(&user_file), Some(&project_file))
.await
.unwrap();
assert_eq!(config.servers.len(), 2);
assert_eq!(config.servers[0].name, "project-server");
assert_eq!(config.servers[1].name, "user-server");
}
#[tokio::test]
async fn test_find_project_mcp_does_not_walk_up_tree() {
let temp = TempDir::new().unwrap();
let parent_config = temp.path().join(".rkat");
tokio::fs::create_dir_all(&parent_config).await.unwrap();
tokio::fs::write(
parent_config.join("mcp.toml"),
r#"
[[servers]]
name = "parent-server"
command = "should-not-load"
"#,
)
.await
.unwrap();
let child_dir = temp.path().join("child");
tokio::fs::create_dir_all(&child_dir).await.unwrap();
let result = find_project_mcp_in(&child_dir);
assert!(
result.is_none(),
"Should not find config in parent directory"
);
let result = find_project_mcp_in(temp.path());
assert!(result.is_some(), "Should find config in current directory");
}
#[tokio::test]
async fn test_find_project_mcp_finds_config_in_current_dir() {
let temp = TempDir::new().unwrap();
let meerkat_dir = temp.path().join(".rkat");
tokio::fs::create_dir_all(&meerkat_dir).await.unwrap();
let config_path = meerkat_dir.join("mcp.toml");
tokio::fs::write(
&config_path,
r#"
[[servers]]
name = "local-server"
command = "echo"
"#,
)
.await
.unwrap();
let result = find_project_mcp_in(temp.path());
assert_eq!(result, Some(config_path));
}
#[test]
fn test_http_transport_defaults_to_streamable() {
let toml = r#"
[[servers]]
name = "remote"
url = "https://mcp.example.com/mcp"
"#;
let config: McpConfig = toml::from_str(toml).unwrap();
assert_eq!(config.servers.len(), 1);
assert_eq!(
config.servers[0].transport_kind(),
McpTransportKind::StreamableHttp
);
}
#[test]
fn test_http_transport_sse() {
let toml = r#"
[[servers]]
name = "legacy"
url = "https://old.example.com/sse"
transport = "sse"
"#;
let config: McpConfig = toml::from_str(toml).unwrap();
assert_eq!(config.servers.len(), 1);
assert_eq!(config.servers[0].transport_kind(), McpTransportKind::Sse);
}
#[test]
fn test_rejects_conflicting_transport_fields() {
let toml = r#"
[[servers]]
name = "invalid"
command = "cmd"
url = "https://example.com/mcp"
"#;
let parsed: Result<McpConfig, _> = toml::from_str(toml);
assert!(parsed.is_err(), "Config with command + url should fail");
}
#[tokio::test]
async fn test_env_expansion_in_config() {
let parsed: McpConfig = toml::from_str(
r#"
[[servers]]
name = "remote"
url = "https://mcp.example.com/mcp"
headers = { Authorization = "Bearer ${RKAT_TEST_API_KEY}" }
"#,
)
.unwrap();
let env = HashMap::from([("RKAT_TEST_API_KEY".to_string(), "secret".to_string())]);
let config = expand_env_in_config_with(parsed, &|key| env.get(key).cloned()).unwrap();
let server = &config.servers[0];
match &server.transport {
McpTransportConfig::Http(http) => {
assert_eq!(
http.headers.get("Authorization"),
Some(&"Bearer secret".to_string())
);
}
McpTransportConfig::Stdio(_) => unreachable!("Expected http transport"),
}
}
#[tokio::test]
async fn test_load_with_scopes_from_roots_precedence_and_dedup() {
let temp = TempDir::new().unwrap();
let context_root = temp.path().join("context");
let user_root = temp.path().join("user");
tokio::fs::create_dir_all(context_root.join(".rkat"))
.await
.unwrap();
tokio::fs::create_dir_all(user_root.join(".rkat"))
.await
.unwrap();
tokio::fs::write(
context_root.join(".rkat/mcp.toml"),
r#"
[[servers]]
name = "shared"
command = "context-cmd"
[[servers]]
name = "context-only"
command = "context-only-cmd"
"#,
)
.await
.unwrap();
tokio::fs::write(
user_root.join(".rkat/mcp.toml"),
r#"
[[servers]]
name = "shared"
command = "user-cmd"
[[servers]]
name = "user-only"
command = "user-only-cmd"
"#,
)
.await
.unwrap();
let merged = McpConfig::load_with_scopes_from_roots(Some(&context_root), Some(&user_root))
.await
.unwrap();
let names: Vec<String> = merged.iter().map(|s| s.server.name.clone()).collect();
assert_eq!(names, vec!["shared", "context-only", "user-only"]);
assert_eq!(merged[0].scope, McpScope::Project);
}
#[tokio::test]
async fn test_load_with_scopes_from_roots_none_is_empty() {
let merged = McpConfig::load_with_scopes_from_roots(None, None)
.await
.unwrap();
assert!(merged.is_empty());
}
#[tokio::test]
async fn test_load_scope_from_roots_reads_unmerged_scope() {
let temp = TempDir::new().unwrap();
let context_root = temp.path().join("context");
let user_root = temp.path().join("user");
tokio::fs::create_dir_all(context_root.join(".rkat"))
.await
.unwrap();
tokio::fs::create_dir_all(user_root.join(".rkat"))
.await
.unwrap();
tokio::fs::write(
context_root.join(".rkat/mcp.toml"),
r#"
[[servers]]
name = "shared"
command = "context-cmd"
"#,
)
.await
.unwrap();
tokio::fs::write(
user_root.join(".rkat/mcp.toml"),
r#"
[[servers]]
name = "shared"
command = "user-cmd"
"#,
)
.await
.unwrap();
let user =
McpConfig::load_scope_from_roots(McpScope::User, Some(&context_root), Some(&user_root))
.await
.unwrap();
assert_eq!(user.servers[0].name, "shared");
let command = match &user.servers[0].transport {
McpTransportConfig::Stdio(stdio) => Some(stdio.command.as_str()),
McpTransportConfig::Http(_) => None,
};
assert_eq!(command, Some("user-cmd"));
}
#[tokio::test]
async fn test_find_server_scopes_from_roots_never_reads_ambient_paths() {
let temp = TempDir::new().unwrap();
let context_root = temp.path().join("context");
let user_root = temp.path().join("user");
tokio::fs::create_dir_all(context_root.join(".rkat"))
.await
.unwrap();
tokio::fs::create_dir_all(user_root.join(".rkat"))
.await
.unwrap();
let shared = r#"
[[servers]]
name = "shared"
command = "echo"
"#;
tokio::fs::write(context_root.join(".rkat/mcp.toml"), shared)
.await
.unwrap();
tokio::fs::write(user_root.join(".rkat/mcp.toml"), shared)
.await
.unwrap();
let scopes = McpConfig::find_server_scopes_from_roots(
"shared",
Some(&context_root),
Some(&user_root),
)
.await
.unwrap();
assert_eq!(scopes, vec![McpScope::Project, McpScope::User]);
}
#[test]
fn test_mutation_authority_resolves_each_explicit_convention_root() {
let context_root = PathBuf::from("/explicit/context");
let user_root = PathBuf::from("/explicit/user");
let project = McpConfigMutationAuthority::for_scope(
McpScope::Project,
Some(context_root.clone()),
Some(user_root.clone()),
);
let user = McpConfigMutationAuthority::for_scope(
McpScope::User,
Some(context_root.clone()),
Some(user_root.clone()),
);
assert_eq!(
project.resolved_path().unwrap(),
context_root.join(".rkat/mcp.toml")
);
assert_eq!(
user.resolved_path().unwrap(),
user_root.join(".rkat/mcp.toml")
);
}
#[tokio::test]
async fn test_persist_add_uses_project_authority_and_rolls_back_new_file() {
let temp = TempDir::new().unwrap();
let authority = McpConfigMutationAuthority::project(Some(temp.path().to_path_buf()), None);
let server = McpServerConfig::stdio("persisted", "echo", vec!["ok".into()], HashMap::new());
let rollback = McpConfig::persist_add_with_rollback(&authority, server)
.await
.unwrap();
let path = temp.path().join(".rkat/mcp.toml");
let config = McpConfig::load_from_paths(None, Some(&path)).await.unwrap();
assert_eq!(config.servers.len(), 1);
assert_eq!(config.servers[0].name, "persisted");
rollback.rollback().await.unwrap();
assert!(
!path.exists(),
"rollback should remove a newly-created config"
);
}
#[tokio::test]
async fn test_persist_add_preserves_comments_and_forward_compatible_keys() {
let temp = TempDir::new().unwrap();
tokio::fs::create_dir_all(temp.path().join(".rkat"))
.await
.unwrap();
let path = temp.path().join(".rkat/mcp.toml");
let original = r#"# operator-owned heading
future_config = "leave-me"
[[servers]]
# server note
name = "existing"
command = "existing-cmd"
future_server_key = "leave-this-too"
"#;
tokio::fs::write(&path, original).await.unwrap();
let authority = McpConfigMutationAuthority::project(Some(temp.path().to_path_buf()), None);
assert!(
McpConfig::server_exists_from_roots(
"existing",
McpScope::Project,
Some(temp.path()),
None,
)
.await
.unwrap(),
"scope discovery must tolerate forward-compatible document fields"
);
let server = McpServerConfig::stdio("added", "echo", vec!["ok".into()], HashMap::new());
McpConfig::persist_add_with_rollback(&authority, server)
.await
.unwrap();
let persisted = tokio::fs::read_to_string(&path).await.unwrap();
assert!(persisted.contains("# operator-owned heading"));
assert!(persisted.contains("future_config = \"leave-me\""));
assert!(persisted.contains("# server note"));
assert!(persisted.contains("future_server_key = \"leave-this-too\""));
assert!(persisted.contains("name = \"added\""));
}
#[tokio::test]
async fn test_persist_add_serializes_sse_headers_and_timeout() {
let temp = TempDir::new().unwrap();
let authority = McpConfigMutationAuthority::project(Some(temp.path().to_path_buf()), None);
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer token".to_string());
let mut server = McpServerConfig::sse("remote", "https://example.test/sse", headers);
server.connect_timeout_secs = Some(42);
McpConfig::persist_add_with_rollback(&authority, server.clone())
.await
.unwrap();
let path = temp.path().join(".rkat/mcp.toml");
let config = McpConfig::load_from_paths(None, Some(&path)).await.unwrap();
assert_eq!(config.servers, vec![server]);
let persisted = tokio::fs::read_to_string(path).await.unwrap();
assert!(persisted.contains("transport = \"sse\""));
assert!(persisted.contains("connect_timeout_secs = 42"));
}
#[tokio::test]
async fn test_concurrent_distinct_adds_both_survive() {
let temp = TempDir::new().unwrap();
let authority = McpConfigMutationAuthority::project(Some(temp.path().to_path_buf()), None);
let first_authority = authority.clone();
let second_authority = authority.clone();
let (first, second) = tokio::join!(
McpConfig::persist_add_with_rollback(
&first_authority,
McpServerConfig::stdio("first", "echo", Vec::new(), HashMap::new()),
),
McpConfig::persist_add_with_rollback(
&second_authority,
McpServerConfig::stdio("second", "echo", Vec::new(), HashMap::new()),
),
);
first.unwrap();
second.unwrap();
let path = temp.path().join(".rkat/mcp.toml");
let mut names = McpConfig::load_from_paths(None, Some(&path))
.await
.unwrap()
.servers
.into_iter()
.map(|server| server.name)
.collect::<Vec<_>>();
names.sort_unstable();
assert_eq!(names, vec!["first", "second"]);
}
#[tokio::test]
async fn test_stale_rollback_preserves_intervening_add() {
let temp = TempDir::new().unwrap();
let authority = McpConfigMutationAuthority::project(Some(temp.path().to_path_buf()), None);
let stale_rollback = McpConfig::persist_add_with_rollback(
&authority,
McpServerConfig::stdio("first", "echo", Vec::new(), HashMap::new()),
)
.await
.unwrap();
McpConfig::persist_add_with_rollback(
&authority,
McpServerConfig::stdio("later", "echo", Vec::new(), HashMap::new()),
)
.await
.unwrap();
let error = stale_rollback.rollback().await.unwrap_err();
assert!(matches!(error, McpConfigError::RollbackConflict { .. }));
let path = temp.path().join(".rkat/mcp.toml");
let mut names = McpConfig::load_from_paths(None, Some(&path))
.await
.unwrap()
.servers
.into_iter()
.map(|server| server.name)
.collect::<Vec<_>>();
names.sort_unstable();
assert_eq!(names, vec!["first", "later"]);
}
#[tokio::test]
async fn test_revision_fence_rejects_byte_identical_aba_rollback() {
let temp = TempDir::new().unwrap();
let authority = McpConfigMutationAuthority::project(Some(temp.path().to_path_buf()), None);
let server = McpServerConfig::stdio("same", "echo", Vec::new(), HashMap::new());
let stale_rollback = McpConfig::persist_add_with_rollback(&authority, server.clone())
.await
.unwrap();
let originally_committed = stale_rollback.committed_bytes.clone();
McpConfig::persist_remove_with_rollback(&authority, "same")
.await
.unwrap();
McpConfig::persist_add_with_rollback(&authority, server)
.await
.unwrap();
let path = temp.path().join(".rkat/mcp.toml");
assert_eq!(
tokio::fs::read(&path).await.unwrap(),
originally_committed,
"exercise an exact byte-level ABA, not merely a semantic rewrite"
);
let error = stale_rollback.rollback().await.unwrap_err();
assert!(matches!(error, McpConfigError::RollbackConflict { .. }));
let config = McpConfig::load_from_paths(None, Some(&path)).await.unwrap();
assert_eq!(config.servers.len(), 1);
assert_eq!(config.servers[0].name, "same");
}
#[tokio::test]
async fn test_persist_remove_rolls_back_previous_bytes() {
let temp = TempDir::new().unwrap();
tokio::fs::create_dir_all(temp.path().join(".rkat"))
.await
.unwrap();
let path = temp.path().join(".rkat/mcp.toml");
let original = r#"
# preserve this file-level comment
future_config = "leave-me"
[[servers]]
name = "keep"
command = "keep-cmd"
future_server_key = "leave-this-too"
[[servers]]
name = "remove"
command = "remove-cmd"
"#;
tokio::fs::write(&path, original).await.unwrap();
let authority = McpConfigMutationAuthority::project(Some(temp.path().to_path_buf()), None);
let rollback = McpConfig::persist_remove_with_rollback(&authority, "remove")
.await
.unwrap();
let persisted = tokio::fs::read_to_string(&path).await.unwrap();
assert!(persisted.contains("# preserve this file-level comment"));
assert!(persisted.contains("future_config = \"leave-me\""));
assert!(persisted.contains("future_server_key = \"leave-this-too\""));
assert!(!persisted.contains("name = \"remove\""));
rollback.rollback().await.unwrap();
let restored = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(restored, original);
}
}