use super::*;
use crate::commands::skills::resolve_tools;
use serde_json::{Value as JsonValue, json};
use std::path::{Path, PathBuf};
const REMOTE_MCP_URL: &str = "https://mcp.railway.com";
#[derive(Parser)]
pub struct Args {
#[clap(long)]
agent: Vec<String>,
#[clap(long)]
remote: bool,
}
pub async fn command(args: Args) -> Result<()> {
install_mcp(&args.agent, args.remote).await
}
pub(crate) async fn install_mcp(agent_filter: &[String], remote: bool) -> Result<()> {
let home = dirs::home_dir().context("could not determine home directory")?;
let tools = resolve_tools(&home, agent_filter)?;
println!("\n{}\n", "Railway MCP".bold());
let configurable: Vec<_> = tools
.iter()
.filter(|t| supports_mcp(t.slug))
.cloned()
.collect();
if configurable.is_empty() {
println!("{}", "No MCP-capable tools selected or detected.".yellow());
if tools.iter().any(|t| t.slug == "universal") {
println!(
"{} The universal `.agents` directory has no MCP convention; pass --agent to target a specific tool.",
"!".yellow().bold()
);
}
return Ok(());
}
let names: Vec<_> = configurable.iter().map(|t| t.name).collect();
let transport = if remote {
format!("remote ({})", REMOTE_MCP_URL).cyan()
} else {
"local stdio".cyan()
};
println!(
"{} {} {} {}\n",
"Installing".bold(),
transport,
"to:".bold(),
names.join(", ")
);
for tool in &configurable {
let path = config_path(tool.slug, &home);
match install_for(tool.slug, &path, remote) {
Ok(()) => {
println!(
"{} {}: configured \u{2192} {}",
"\u{2713}".green(),
tool.name.bold(),
path.display().to_string().cyan()
);
}
Err(e) => {
println!(
"{} {}: failed \u{2192} {}",
"\u{2717}".red(),
tool.name.bold(),
e.to_string().red()
);
}
}
}
println!("\n{}", "MCP server installed successfully!".green().bold());
println!(
"{} You may need to restart your tool(s) for the MCP server to register.\n",
"!".yellow().bold()
);
Ok(())
}
fn supports_mcp(slug: &str) -> bool {
matches!(
slug,
"claude-code" | "cursor" | "opencode" | "codex" | "copilot" | "factory-droid"
)
}
fn config_path(slug: &str, home: &Path) -> PathBuf {
match slug {
"claude-code" => home.join(".claude.json"),
"cursor" => home.join(".cursor").join("mcp.json"),
"opencode" => home.join(".config").join("opencode").join("opencode.json"),
"codex" => home.join(".codex").join("config.toml"),
"copilot" => home.join(".copilot").join("mcp-config.json"),
"factory-droid" => home.join(".factory").join("mcp.json"),
_ => home.join(".unsupported"),
}
}
pub(crate) fn mcp_configured_for_slug(home: &Path, slug: &str, remote: bool) -> bool {
let path = config_path(slug, home);
match slug {
"claude-code" | "cursor" | "copilot" | "factory-droid" => read_json_or_empty(&path)
.ok()
.and_then(|root| root.pointer("/mcpServers/railway").cloned())
.is_some_and(|entry| json_mcp_entry_matches(&entry, remote)),
"opencode" => read_json_or_empty(&path)
.ok()
.and_then(|root| root.pointer("/mcp/railway").cloned())
.is_some_and(|entry| opencode_mcp_entry_matches(&entry, remote)),
"codex" => codex_mcp_configured(&path, remote),
_ => false,
}
}
fn json_mcp_entry_matches(entry: &JsonValue, remote: bool) -> bool {
if remote {
entry.get("url").and_then(JsonValue::as_str) == Some(REMOTE_MCP_URL)
} else {
entry.get("command").and_then(JsonValue::as_str) == Some("railway")
&& entry
.get("args")
.and_then(JsonValue::as_array)
.is_some_and(|args| args.iter().any(|arg| arg.as_str() == Some("mcp")))
}
}
fn opencode_mcp_entry_matches(entry: &JsonValue, remote: bool) -> bool {
if remote {
entry.get("type").and_then(JsonValue::as_str) == Some("remote")
&& entry.get("url").and_then(JsonValue::as_str) == Some(REMOTE_MCP_URL)
} else {
entry.get("type").and_then(JsonValue::as_str) == Some("local")
&& entry
.get("command")
.and_then(JsonValue::as_array)
.is_some_and(|command| {
command.first().and_then(JsonValue::as_str) == Some("railway")
&& command.iter().any(|arg| arg.as_str() == Some("mcp"))
})
}
}
fn codex_mcp_configured(path: &Path, remote: bool) -> bool {
let Ok(existing) = std::fs::read_to_string(path) else {
return false;
};
let Ok(doc) = existing.parse::<toml::Value>() else {
return false;
};
doc.get("mcp_servers")
.and_then(|servers| servers.get("railway"))
.is_some_and(|entry| {
if remote {
entry.get("url").and_then(toml::Value::as_str) == Some(REMOTE_MCP_URL)
} else {
entry.get("command").and_then(toml::Value::as_str) == Some("railway")
&& entry
.get("args")
.and_then(toml::Value::as_array)
.is_some_and(|args| args.iter().any(|arg| arg.as_str() == Some("mcp")))
}
})
}
fn install_for(slug: &str, path: &Path, remote: bool) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
match slug {
"claude-code" => {
let entry = if remote {
json!({ "type": "http", "url": REMOTE_MCP_URL })
} else {
json!({ "command": "railway", "args": ["mcp"] })
};
write_json_mcp_servers(path, entry)
}
"cursor" => {
let entry = if remote {
json!({ "url": REMOTE_MCP_URL })
} else {
json!({ "command": "railway", "args": ["mcp"] })
};
write_json_mcp_servers(path, entry)
}
"opencode" => write_opencode_mcp(path, remote),
"codex" => write_codex_toml(path, remote),
"copilot" => {
let entry = if remote {
json!({ "type": "http", "url": REMOTE_MCP_URL, "tools": ["*"] })
} else {
json!({
"type": "local",
"command": "railway",
"args": ["mcp"],
"tools": ["*"]
})
};
write_json_mcp_servers(path, entry)
}
"factory-droid" => {
let entry = if remote {
json!({ "type": "http", "url": REMOTE_MCP_URL, "disabled": false })
} else {
json!({
"type": "stdio",
"command": "railway",
"args": ["mcp"],
"disabled": false
})
};
write_json_mcp_servers(path, entry)
}
_ => bail!("Unsupported MCP target: {}", slug),
}
}
fn write_json_mcp_servers(path: &Path, entry: JsonValue) -> Result<()> {
let mut root = read_json_or_empty(path)?;
let obj = root
.as_object_mut()
.context("Existing config root is not a JSON object")?;
let servers = obj
.entry("mcpServers".to_string())
.or_insert_with(|| JsonValue::Object(Default::default()));
let servers = servers
.as_object_mut()
.context("`mcpServers` is not a JSON object")?;
servers.insert("railway".to_string(), entry);
write_json_pretty(path, &root)
}
fn write_opencode_mcp(path: &Path, remote: bool) -> Result<()> {
let mut root = read_json_or_empty(path)?;
let entry = if remote {
json!({
"type": "remote",
"url": REMOTE_MCP_URL,
"enabled": true,
})
} else {
json!({
"type": "local",
"command": ["railway", "mcp"],
"enabled": true,
})
};
let obj = root
.as_object_mut()
.context("Existing config root is not a JSON object")?;
let servers = obj
.entry("mcp".to_string())
.or_insert_with(|| JsonValue::Object(Default::default()));
let servers = servers
.as_object_mut()
.context("`mcp` is not a JSON object")?;
servers.insert("railway".to_string(), entry);
if !obj.contains_key("$schema") {
obj.insert(
"$schema".to_string(),
JsonValue::String("https://opencode.ai/config.json".to_string()),
);
}
write_json_pretty(path, &root)
}
fn write_codex_toml(path: &Path, remote: bool) -> Result<()> {
let existing = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).with_context(|| format!("Failed to read {}", path.display())),
};
let mut doc: toml::Value = if existing.trim().is_empty() {
toml::Value::Table(Default::default())
} else {
existing
.parse::<toml::Value>()
.with_context(|| format!("Failed to parse existing TOML at {}", path.display()))?
};
let table = doc
.as_table_mut()
.context("Existing config root is not a TOML table")?;
let servers = table
.entry("mcp_servers".to_string())
.or_insert_with(|| toml::Value::Table(Default::default()));
let servers = servers
.as_table_mut()
.context("`mcp_servers` is not a TOML table")?;
let mut railway = toml::value::Table::new();
if remote {
railway.insert(
"url".to_string(),
toml::Value::String(REMOTE_MCP_URL.to_string()),
);
} else {
railway.insert(
"command".to_string(),
toml::Value::String("railway".to_string()),
);
railway.insert(
"args".to_string(),
toml::Value::Array(vec![toml::Value::String("mcp".to_string())]),
);
}
servers.insert("railway".to_string(), toml::Value::Table(railway));
let serialized = toml::to_string_pretty(&doc).context("Failed to serialize TOML")?;
crate::util::write_atomic(path, &serialized)
.with_context(|| format!("Failed to write {}", path.display()))
}
fn read_json_or_empty(path: &Path) -> Result<JsonValue> {
match std::fs::read_to_string(path) {
Ok(s) if s.trim().is_empty() => Ok(JsonValue::Object(Default::default())),
Ok(s) => match serde_json::from_str(&s) {
Ok(v) => Ok(v),
Err(_) => serde_json::from_str(&strip_jsonc(&s))
.with_context(|| format!("Failed to parse existing JSON at {}", path.display())),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(JsonValue::Object(Default::default()))
}
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
}
}
fn strip_jsonc(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = String::with_capacity(input.len());
let mut i = 0;
let mut in_string = false;
let mut escape = false;
while i < bytes.len() {
let c = bytes[i];
if in_string {
out.push(c as char);
if escape {
escape = false;
} else if c == b'\\' {
escape = true;
} else if c == b'"' {
in_string = false;
}
i += 1;
continue;
}
if c == b'"' {
in_string = true;
out.push('"');
i += 1;
continue;
}
if c == b'/' && i + 1 < bytes.len() {
match bytes[i + 1] {
b'/' => {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
b'*' => {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i = (i + 2).min(bytes.len());
continue;
}
_ => {}
}
}
if c == b',' {
let mut j = i + 1;
while j < bytes.len() && (bytes[j] as char).is_whitespace() {
j += 1;
}
if j < bytes.len() && (bytes[j] == b'}' || bytes[j] == b']') {
i += 1;
continue;
}
}
out.push(c as char);
i += 1;
}
out
}
fn write_json_pretty(path: &Path, value: &JsonValue) -> Result<()> {
let s = serde_json::to_string_pretty(value).context("Failed to serialize JSON")?;
crate::util::write_atomic(path, &s)
.with_context(|| format!("Failed to write {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_existing_cursor_local_mcp() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join(".cursor").join("mcp.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"{
// Existing user config may be JSONC.
"mcpServers": {
"railway": { "command": "railway", "args": ["mcp"] },
}
}"#,
)
.unwrap();
assert!(mcp_configured_for_slug(home.path(), "cursor", false));
assert!(!mcp_configured_for_slug(home.path(), "cursor", true));
}
#[test]
fn detects_existing_opencode_remote_mcp() {
let home = tempfile::tempdir().unwrap();
let path = home
.path()
.join(".config")
.join("opencode")
.join("opencode.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"{
"mcp": {
"railway": {
"type": "remote",
"url": "https://mcp.railway.com",
"enabled": true
}
}
}"#,
)
.unwrap();
assert!(mcp_configured_for_slug(home.path(), "opencode", true));
assert!(!mcp_configured_for_slug(home.path(), "opencode", false));
}
#[test]
fn detects_existing_codex_local_mcp() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join(".codex").join("config.toml");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"
[mcp_servers.railway]
command = "railway"
args = ["mcp"]
"#,
)
.unwrap();
assert!(mcp_configured_for_slug(home.path(), "codex", false));
assert!(!mcp_configured_for_slug(home.path(), "codex", true));
}
#[test]
fn detects_existing_codex_remote_mcp() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join(".codex").join("config.toml");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"
[mcp_servers.railway]
url = "https://mcp.railway.com"
"#,
)
.unwrap();
assert!(mcp_configured_for_slug(home.path(), "codex", true));
assert!(!mcp_configured_for_slug(home.path(), "codex", false));
}
#[test]
fn writes_codex_remote_mcp() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join(".codex").join("config.toml");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
write_codex_toml(&path, true).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
let doc = written.parse::<toml::Value>().unwrap();
let railway = doc
.get("mcp_servers")
.and_then(|servers| servers.get("railway"))
.unwrap();
assert_eq!(
railway.get("url").and_then(toml::Value::as_str),
Some("https://mcp.railway.com")
);
assert!(railway.get("command").is_none());
assert!(railway.get("args").is_none());
}
#[test]
fn writes_copilot_local_mcp() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join(".copilot").join("mcp-config.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
install_for("copilot", &path, false).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
let root: JsonValue = serde_json::from_str(&written).unwrap();
let railway = root.pointer("/mcpServers/railway").unwrap();
assert_eq!(
railway.get("type").and_then(JsonValue::as_str),
Some("local")
);
assert_eq!(
railway.get("command").and_then(JsonValue::as_str),
Some("railway")
);
assert!(mcp_configured_for_slug(home.path(), "copilot", false));
}
#[test]
fn writes_factory_droid_remote_mcp() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join(".factory").join("mcp.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
install_for("factory-droid", &path, true).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
let root: JsonValue = serde_json::from_str(&written).unwrap();
let railway = root.pointer("/mcpServers/railway").unwrap();
assert_eq!(
railway.get("type").and_then(JsonValue::as_str),
Some("http")
);
assert_eq!(
railway.get("url").and_then(JsonValue::as_str),
Some("https://mcp.railway.com")
);
assert!(mcp_configured_for_slug(home.path(), "factory-droid", true));
}
}