use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use super::prefs::AgentPrefs;
const RESERVED_NAMES: &[&str] = &["railway", "playwright", "railway-machine"];
const NAME_PREFIX: &str = "user-";
const MAX_BYTES: usize = 256 * 1024;
#[derive(Debug)]
pub struct PackedMcp {
pub payload: Vec<u8>,
pub hash: String,
pub names: Vec<String>,
pub source_path: PathBuf,
}
pub fn find_config(dir: &Path) -> Option<PathBuf> {
let in_repo = |d: &Path| d.ancestors().any(|a| a.join(".git").exists());
let mut cur = Some(dir);
while let Some(d) = cur {
let candidate = d.join(".mcp.json");
if candidate.is_file() {
return (d == dir || in_repo(dir)).then_some(candidate);
}
if d.join(".git").exists() {
return None;
}
cur = d.parent();
}
None
}
pub fn pack(prefs: &AgentPrefs, dir: &Path) -> Result<Option<PackedMcp>> {
if !prefs.mcp.enabled {
return Ok(None);
}
let Some(source_path) = find_config(dir) else {
return Ok(None);
};
let raw = std::fs::read_to_string(&source_path)
.with_context(|| format!("Failed to read {}", source_path.display()))?;
let parsed: serde_json::Value = serde_json::from_str(&raw)
.with_context(|| format!("{} is not valid JSON", source_path.display()))?;
let Some(servers) = parsed.get("mcpServers").and_then(|v| v.as_object()) else {
return Ok(None);
};
let mut shipped: BTreeMap<String, serde_json::Value> = BTreeMap::new();
for (name, config) in servers {
if RESERVED_NAMES.contains(&name.as_str())
|| prefs.mcp.exclude.iter().any(|e| e == name)
|| config.get("disabled").and_then(|d| d.as_bool()) == Some(true)
{
continue;
}
let mut config = config.clone();
if let Some(obj) = config.as_object_mut() {
obj.remove("disabled");
}
let shipped_name = if name.starts_with(NAME_PREFIX) {
name.clone()
} else {
format!("{NAME_PREFIX}{name}")
};
shipped.insert(shipped_name, config);
}
if shipped.is_empty() {
return Ok(None);
}
let payload = serde_json::to_vec(&shipped)?;
if payload.len() > MAX_BYTES {
anyhow::bail!(
"{} is too large to sync ({} bytes, limit {MAX_BYTES}).",
source_path.display(),
payload.len()
);
}
let hash = format!("{:x}", Sha256::digest(&payload));
Ok(Some(PackedMcp {
payload,
hash,
names: shipped.into_keys().collect(),
source_path,
}))
}
pub const REMOTE_HASH_MARKER: &str = "MCP-HASH:";
pub const REMOTE_HASH_FILE: &str = "$HOME/.railway-mcp-hash";
pub fn parse_remote_hash(provision_output: &str) -> Option<String> {
provision_output
.lines()
.find_map(|line| line.trim().strip_prefix(REMOTE_HASH_MARKER))
.map(str::to_string)
.filter(|h| !h.is_empty())
}
pub fn provision_script(hash: &str) -> String {
format!(
r#"umask 077
# Read stdin FIRST, before anything that can bail: exiting with the pipe still
# full breaks the CLI's write instead of the sync.
payload="$HOME/.railway-mcp-payload.json"
cat > "$payload"
command -v jq >/dev/null 2>&1 || {{ rm -f "$payload"; echo MCP-NO-JQ; exit 0; }}
jq -e 'type == "object"' "$payload" >/dev/null 2>&1 || {{ rm -f "$payload"; echo MCP-BAD-JSON; exit 0; }}
# The canonical copy, for the platform: express-agent's boot reconcile reads
# this and renders the servers into every harness dialect it has verified —
# the TOML ones (codex, grok) included, which the shell merges below cannot
# reach. The direct merges stay as the compatibility path for images whose
# express-agent predates the file.
cp "$payload" "$HOME/.railway-mcp.json"
ok=1
# claude's user scope: mcpServers object. The payload's keys all wear the
# import prefix, and prefixed names are OURS to keep current — an edited url
# in the project's .mcp.json must land, or the hash below would record a
# sync that never happened. Every other name (the user's own, or
# express-agent's) is preserved untouched.
cfg="$HOME/.claude.json"
[ -s "$cfg" ] || echo '{{}}' > "$cfg"
if jq --slurpfile new "$payload" '.mcpServers = ((.mcpServers // {{}}) + $new[0])' "$cfg" > "$cfg.railway-mcp-tmp" 2>/dev/null; then
mv "$cfg.railway-mcp-tmp" "$cfg"
else
rm -f "$cfg.railway-mcp-tmp"; ok=0
fi
# The railway harness reads settings.json's mcp_servers array. Same
# ownership rule in array form: entries under the payload's names are
# replaced with the payload's current content (appended at the tail), and
# every other entry keeps its place.
mkdir -p "$HOME/.claude"
set="$HOME/.claude/settings.json"
[ -s "$set" ] || echo '{{}}' > "$set"
if jq --slurpfile new "$payload" '
($new[0] | keys) as $ours
| .mcp_servers = ((.mcp_servers // [])
| map(select(.name as $n | $ours | index($n) | not)))
+ ($new[0]
| to_entries
| map({{name: .key}}
+ (if .value.url then {{transport: (.value.type // "http"), url: .value.url}}
else {{transport: "stdio", command: (.value.command // ""), args: (.value.args // [])}} end)
+ (if .value.headers then {{headers: .value.headers}} else {{}} end)
+ (if .value.env then {{env: .value.env}} else {{}} end)))
' "$set" > "$set.railway-mcp-tmp" 2>/dev/null; then
mv "$set.railway-mcp-tmp" "$set"
else
rm -f "$set.railway-mcp-tmp"; ok=0
fi
rm -f "$payload"
[ "$ok" = 1 ] || {{ echo MCP-MERGE-FAILED; exit 0; }}
printf '%s\n' '{hash}' > "{REMOTE_HASH_FILE}"
echo MCP-OK"#
)
}
#[cfg(test)]
mod tests {
use super::*;
fn prefs_on() -> AgentPrefs {
AgentPrefs::default()
}
fn plant(dir: &Path, body: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(".mcp.json"), body).unwrap();
}
const MONO_LIKE: &str = r#"{
"mcpServers": {
"railway-internal": { "type": "http", "url": "https://mcp.internal.example.com/" },
"notion": { "type": "http", "url": "https://mcp.notion.com/mcp", "disabled": true },
"buildkite": { "type": "http", "url": "https://mcp.buildkite.com/mcp", "disabled": false },
"railway": { "type": "http", "url": "https://should-never-ship.example.com" },
"local-tool": { "command": "my-mcp", "args": ["--serve"] }
}
}"#;
#[test]
fn packs_enabled_servers_and_filters_the_rest() {
let dir = tempfile::tempdir().unwrap();
plant(dir.path(), MONO_LIKE);
let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();
assert_eq!(
packed.names,
vec![
"user-buildkite".to_string(),
"user-local-tool".to_string(),
"user-railway-internal".to_string()
],
"disabled entries and the platform's own names stay home; \
what ships wears the import prefix"
);
let shipped: serde_json::Value = serde_json::from_slice(&packed.payload).unwrap();
assert!(shipped["user-buildkite"].get("disabled").is_none());
assert_eq!(
shipped["user-buildkite"]["url"],
"https://mcp.buildkite.com/mcp"
);
}
#[test]
fn the_prefix_never_doubles() {
let dir = tempfile::tempdir().unwrap();
plant(
dir.path(),
r#"{"mcpServers": {"user-foo": {"type": "http", "url": "https://x.example"}}}"#,
);
let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();
assert_eq!(packed.names, vec!["user-foo".to_string()]);
}
#[test]
fn walks_up_to_the_git_root_and_not_past_it() {
let root = tempfile::tempdir().unwrap();
let repo = root.path().join("repo");
let deep = repo.join("packages").join("thing");
std::fs::create_dir_all(&deep).unwrap();
std::fs::create_dir_all(repo.join(".git")).unwrap();
plant(&repo, MONO_LIKE);
assert_eq!(
find_config(&deep).unwrap(),
repo.join(".mcp.json"),
"a launch from a subdirectory finds the repo's file"
);
let bare = root.path().join("bare");
std::fs::create_dir_all(bare.join(".git")).unwrap();
plant(root.path(), MONO_LIKE);
assert!(find_config(&bare).is_none());
}
#[test]
fn outside_a_repo_only_the_launch_directory_is_read() {
let root = tempfile::tempdir().unwrap();
let scratch = root.path().join("scratch").join("notes");
std::fs::create_dir_all(&scratch).unwrap();
plant(root.path(), MONO_LIKE);
assert!(
find_config(&scratch).is_none(),
"an out-of-repo ancestor's file must stay home"
);
plant(&scratch, MONO_LIKE);
assert_eq!(find_config(&scratch).unwrap(), scratch.join(".mcp.json"));
}
#[test]
fn disabled_pref_missing_file_or_empty_set_pack_nothing() {
let dir = tempfile::tempdir().unwrap();
assert!(pack(&prefs_on(), dir.path()).unwrap().is_none(), "no file");
plant(
dir.path(),
r#"{"mcpServers": {"railway": {"type": "http", "url": "x"}}}"#,
);
assert!(
pack(&prefs_on(), dir.path()).unwrap().is_none(),
"everything filtered"
);
plant(dir.path(), MONO_LIKE);
let mut off = prefs_on();
off.mcp.enabled = false;
assert!(pack(&off, dir.path()).unwrap().is_none(), "pref off");
}
#[test]
fn excluded_names_stay_home() {
let dir = tempfile::tempdir().unwrap();
plant(dir.path(), MONO_LIKE);
let mut prefs = prefs_on();
prefs.mcp.exclude = vec!["railway-internal".into()];
let packed = pack(&prefs, dir.path()).unwrap().unwrap();
assert!(!packed.names.iter().any(|n| n.contains("railway-internal")));
}
#[test]
fn malformed_json_is_an_error_not_a_silent_skip() {
let dir = tempfile::tempdir().unwrap();
plant(dir.path(), "{ not json");
assert!(pack(&prefs_on(), dir.path()).is_err());
}
#[test]
fn hash_tracks_content() {
let dir = tempfile::tempdir().unwrap();
plant(dir.path(), MONO_LIKE);
let first = pack(&prefs_on(), dir.path()).unwrap().unwrap();
let again = pack(&prefs_on(), dir.path()).unwrap().unwrap();
assert_eq!(first.hash, again.hash);
plant(
dir.path(),
r#"{"mcpServers": {"other": {"type": "http", "url": "https://x.example"}}}"#,
);
let changed = pack(&prefs_on(), dir.path()).unwrap().unwrap();
assert_ne!(first.hash, changed.hash);
}
#[test]
fn parses_the_remote_hash_marker() {
assert_eq!(
parse_remote_hash("AGENT-READY\nMCP-HASH:abc\n").as_deref(),
Some("abc")
);
assert!(parse_remote_hash("AGENT-READY\nMCP-HASH:\n").is_none());
assert!(parse_remote_hash("AGENT-READY\n").is_none());
}
#[test]
fn provision_script_drains_stdin_first_and_records_the_hash_last() {
let script = provision_script("deadbeef");
let cat_at = script.find("cat > \"$payload\"").unwrap();
for marker in ["MCP-NO-JQ", "MCP-BAD-JSON", "MCP-MERGE-FAILED"] {
assert!(
script.find(marker).unwrap() > cat_at,
"`{marker}` can be reached before stdin is drained"
);
}
let hash_at = script.find("deadbeef").unwrap();
let ok_at = script.find("echo MCP-OK").unwrap();
let merge_at = script.find("mcpServers =").unwrap();
assert!(merge_at < hash_at && hash_at < ok_at);
}
#[cfg(unix)]
#[test]
fn provision_script_upserts_owned_names_and_preserves_the_rest() {
use std::process::Command;
if Command::new("jq").arg("--version").output().is_err() {
eprintln!("skipping: jq not installed");
return;
}
let dir = tempfile::tempdir().unwrap();
plant(dir.path(), MONO_LIKE);
let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();
let vm = tempfile::tempdir().unwrap();
std::fs::write(
vm.path().join(".claude.json"),
r#"{"hasCompletedOnboarding": true, "mcpServers": {"user-buildkite": {"type": "http", "url": "https://stale.example"}, "mine": {"command": "hands-off"}}}"#,
)
.unwrap();
std::fs::create_dir_all(vm.path().join(".claude")).unwrap();
std::fs::write(
vm.path().join(".claude").join("settings.json"),
r#"{"mcp_servers": [{"name": "railway", "transport": "stdio", "command": "express-agent"}, {"name": "user-buildkite", "transport": "http", "url": "https://stale.example"}]}"#,
)
.unwrap();
let stdout = run_script(vm.path(), &packed.payload, &packed.hash);
assert!(stdout.contains("MCP-OK"), "{stdout}");
let cfg: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(vm.path().join(".claude.json")).unwrap())
.unwrap();
assert_eq!(cfg["hasCompletedOnboarding"], true);
assert_eq!(
cfg["mcpServers"]["user-buildkite"]["url"], "https://mcp.buildkite.com/mcp",
"an edited server propagates on the next sync"
);
assert_eq!(
cfg["mcpServers"]["mine"]["command"], "hands-off",
"names outside the prefix are never touched"
);
assert_eq!(
cfg["mcpServers"]["user-railway-internal"]["url"],
"https://mcp.internal.example.com/"
);
let set: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(vm.path().join(".claude").join("settings.json")).unwrap(),
)
.unwrap();
let servers = set["mcp_servers"].as_array().unwrap();
assert_eq!(servers[0]["name"], "railway", "existing entries lead");
let buildkites: Vec<_> = servers
.iter()
.filter(|s| s["name"] == "user-buildkite")
.collect();
assert_eq!(
buildkites.len(),
1,
"an owned name is replaced, not duplicated"
);
assert_eq!(buildkites[0]["url"], "https://mcp.buildkite.com/mcp");
let internal = servers
.iter()
.find(|s| s["name"] == "user-railway-internal")
.expect("http server appended");
assert_eq!(internal["transport"], "http");
let local = servers
.iter()
.find(|s| s["name"] == "user-local-tool")
.expect("stdio server appended");
assert_eq!(local["transport"], "stdio");
assert_eq!(local["command"], "my-mcp");
let canonical = std::fs::read(vm.path().join(".railway-mcp.json")).unwrap();
assert_eq!(canonical, packed.payload);
let recorded = std::fs::read_to_string(vm.path().join(".railway-mcp-hash")).unwrap();
assert_eq!(recorded.trim(), packed.hash);
assert!(!vm.path().join(".railway-mcp-payload.json").exists());
}
#[cfg(unix)]
fn run_script(vm: &Path, payload: &[u8], hash: &str) -> String {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new("sh")
.arg("-c")
.arg(provision_script(hash))
.env("HOME", vm)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(payload).unwrap();
let out = child.wait_with_output().unwrap();
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[cfg(unix)]
#[test]
fn provision_script_leaves_corrupt_configs_untouched() {
use std::process::Command;
if Command::new("jq").arg("--version").output().is_err() {
eprintln!("skipping: jq not installed");
return;
}
let dir = tempfile::tempdir().unwrap();
plant(dir.path(), MONO_LIKE);
let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();
let vm = tempfile::tempdir().unwrap();
std::fs::write(vm.path().join(".claude.json"), "{ definitely not json").unwrap();
std::fs::create_dir_all(vm.path().join(".claude")).unwrap();
std::fs::write(
vm.path().join(".claude").join("settings.json"),
r#"{"mcp_servers": {"wrong": "shape"}}"#,
)
.unwrap();
let stdout = run_script(vm.path(), &packed.payload, &packed.hash);
assert!(stdout.contains("MCP-MERGE-FAILED"), "{stdout}");
assert!(!stdout.contains("MCP-OK"), "{stdout}");
assert_eq!(
std::fs::read_to_string(vm.path().join(".claude.json")).unwrap(),
"{ definitely not json"
);
assert_eq!(
std::fs::read_to_string(vm.path().join(".claude").join("settings.json")).unwrap(),
r#"{"mcp_servers": {"wrong": "shape"}}"#
);
assert!(!vm.path().join(".railway-mcp-hash").exists());
assert!(!vm.path().join(".railway-mcp-payload.json").exists());
assert!(!vm.path().join(".claude.json.railway-mcp-tmp").exists());
assert!(
!vm.path()
.join(".claude")
.join("settings.json.railway-mcp-tmp")
.exists()
);
assert!(vm.path().join(".railway-mcp.json").exists());
}
#[cfg(unix)]
#[test]
fn provision_script_merges_the_good_file_despite_the_bad_one() {
use std::process::Command;
if Command::new("jq").arg("--version").output().is_err() {
eprintln!("skipping: jq not installed");
return;
}
let dir = tempfile::tempdir().unwrap();
plant(dir.path(), MONO_LIKE);
let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();
let vm = tempfile::tempdir().unwrap();
std::fs::write(
vm.path().join(".claude.json"),
r#"{"mcpServers": ["not", "an", "object"]}"#,
)
.unwrap();
let stdout = run_script(vm.path(), &packed.payload, &packed.hash);
assert!(stdout.contains("MCP-MERGE-FAILED"), "{stdout}");
assert_eq!(
std::fs::read_to_string(vm.path().join(".claude.json")).unwrap(),
r#"{"mcpServers": ["not", "an", "object"]}"#
);
let set: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(vm.path().join(".claude").join("settings.json")).unwrap(),
)
.unwrap();
assert!(
set["mcp_servers"]
.as_array()
.unwrap()
.iter()
.any(|s| s["name"] == "user-buildkite"),
"{set}"
);
assert!(!vm.path().join(".railway-mcp-hash").exists());
}
#[cfg(unix)]
#[test]
fn provision_script_is_idempotent() {
use std::process::Command;
if Command::new("jq").arg("--version").output().is_err() {
eprintln!("skipping: jq not installed");
return;
}
let dir = tempfile::tempdir().unwrap();
plant(dir.path(), MONO_LIKE);
let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();
let vm = tempfile::tempdir().unwrap();
assert!(run_script(vm.path(), &packed.payload, &packed.hash).contains("MCP-OK"));
let first_cfg = std::fs::read_to_string(vm.path().join(".claude.json")).unwrap();
let first_set =
std::fs::read_to_string(vm.path().join(".claude").join("settings.json")).unwrap();
assert!(run_script(vm.path(), &packed.payload, &packed.hash).contains("MCP-OK"));
assert_eq!(
std::fs::read_to_string(vm.path().join(".claude.json")).unwrap(),
first_cfg,
"second run left .claude.json byte-identical"
);
assert_eq!(
std::fs::read_to_string(vm.path().join(".claude").join("settings.json")).unwrap(),
first_set,
"second run left settings.json byte-identical"
);
let set: serde_json::Value = serde_json::from_str(&first_set).unwrap();
assert_eq!(
set["mcp_servers"]
.as_array()
.unwrap()
.iter()
.filter(|s| s["name"] == "user-buildkite")
.count(),
1,
"no duplicate array entries across runs"
);
}
}