use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use futures_util::StreamExt;
use serde_json::{Value as JsonValue, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{Mutex, mpsc};
use crate::client::ensure_valid_token;
use crate::commands::Environment;
use crate::config::Configs;
use crate::consts;
const AUTH_ERROR_CODE: i64 = -32001;
const MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
const LOGIN_HINT: &str = "Not logged in to Railway. Run `railway login` in a terminal, then retry \
— the proxy picks up the new login automatically, no restart needed.";
struct ProxyState {
http: reqwest::Client,
url: String,
configs: Mutex<Configs>,
session: Mutex<SessionMeta>,
}
#[derive(Default)]
struct SessionMeta {
id: Option<String>,
init_request: Option<JsonValue>,
}
type Out = mpsc::UnboundedSender<String>;
pub async fn serve_proxy() -> Result<()> {
let configs = Configs::new()?;
let url = resolve_mcp_url(&configs)?;
let http = reqwest::Client::builder()
.danger_accept_invalid_certs(matches!(Configs::get_environment_id(), Environment::Dev))
.user_agent(consts::get_user_agent())
.connect_timeout(Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::none())
.build()
.context("Failed to build HTTP client")?;
let state = Arc::new(ProxyState {
http,
url,
configs: Mutex::new(configs),
session: Mutex::new(SessionMeta::default()),
});
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let writer = tokio::spawn(async move {
let mut stdout = tokio::io::stdout();
while let Some(line) = rx.recv().await {
let _ = stdout.write_all(line.as_bytes()).await;
let _ = stdout.write_all(b"\n").await;
let _ = stdout.flush().await;
}
});
let stdin = BufReader::new(tokio::io::stdin());
let mut lines = stdin.lines();
let mut handshake_done = false;
while let Some(line) = lines.next_line().await? {
if line.trim().is_empty() {
continue;
}
let msg: JsonValue = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
eprintln!("railway mcp proxy: ignoring unparseable message: {e}");
continue;
}
};
if method_of(&msg) == Some("initialize") {
state.session.lock().await.init_request = Some(msg.clone());
}
if handshake_done {
let state = state.clone();
let tx = tx.clone();
tokio::spawn(async move {
handle_message(&state, msg, &tx).await;
});
} else {
let completes_handshake = method_of(&msg) == Some("notifications/initialized");
handle_message(&state, msg, &tx).await;
if completes_handshake {
handshake_done = true;
}
}
}
end_session(&state).await;
drop(tx);
let _ = tokio::time::timeout(Duration::from_secs(5), writer).await;
Ok(())
}
fn method_of(msg: &JsonValue) -> Option<&str> {
msg.get("method").and_then(JsonValue::as_str)
}
fn ids_of(msg: &JsonValue) -> Vec<JsonValue> {
match msg {
JsonValue::Array(items) => items
.iter()
.filter_map(|m| m.get("id").cloned().filter(|id| !id.is_null()))
.collect(),
_ => msg
.get("id")
.cloned()
.filter(|id| !id.is_null())
.into_iter()
.collect(),
}
}
fn resolve_mcp_url(configs: &Configs) -> Result<String> {
let is_dev = matches!(Configs::get_environment_id(), Environment::Dev);
if let Ok(raw) = std::env::var("RAILWAY_MCP_URL") {
if let Some(url) = validate_mcp_override(&raw, is_dev)? {
return Ok(url);
}
}
Ok(format!("https://mcp.{}", configs.get_host()))
}
fn validate_mcp_override(raw: &str, is_dev: bool) -> Result<Option<String>> {
let url = raw.trim();
if url.is_empty() {
return Ok(None);
}
if !url.starts_with("https://") && !is_dev {
anyhow::bail!(
"RAILWAY_MCP_URL must be an https:// URL (got {url:?}); refusing to send credentials over a non-TLS connection."
);
}
Ok(Some(url.trim_end_matches('/').to_string()))
}
async fn handle_message(state: &ProxyState, msg: JsonValue, out: &Out) {
let ids = ids_of(&msg);
let Some(token) = fresh_token(state).await else {
respond_unauthenticated(&msg, &ids, out);
return;
};
if let Err(e) = forward(state, &msg, &token, out).await {
if ids.is_empty() {
eprintln!("railway mcp proxy: {e:#}");
}
for id in &ids {
send_error(out, id, -32603, &format!("Railway MCP proxy error: {e:#}"));
}
}
}
async fn fresh_token(state: &ProxyState) -> Option<String> {
let mut configs = state.configs.lock().await;
if configs.get_railway_auth_token().is_none() {
if let Err(e) = configs.reload() {
eprintln!("railway mcp proxy: config reload failed: {e:#}");
}
}
if let Err(e) = ensure_valid_token(&mut configs).await {
eprintln!("railway mcp proxy: token refresh failed: {e:#}");
}
configs.get_railway_auth_token()
}
fn respond_unauthenticated(msg: &JsonValue, ids: &[JsonValue], out: &Out) {
let [id] = ids else {
for id in ids {
send_error(out, id, AUTH_ERROR_CODE, LOGIN_HINT);
}
return;
};
if method_of(msg) == Some("initialize") {
let protocol = msg
.pointer("/params/protocolVersion")
.and_then(JsonValue::as_str)
.unwrap_or("2025-03-26");
let result = json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": protocol,
"capabilities": { "tools": { "listChanged": true } },
"serverInfo": {
"name": "railway",
"version": env!("CARGO_PKG_VERSION"),
},
"instructions": LOGIN_HINT,
}
});
let _ = out.send(result.to_string());
} else {
send_error(out, id, AUTH_ERROR_CODE, LOGIN_HINT);
}
}
async fn forward(state: &ProxyState, msg: &JsonValue, token: &str, out: &Out) -> Result<()> {
let is_initialize = method_of(msg) == Some("initialize");
let session_id = state.session.lock().await.id.clone();
let resp = post_message(
state,
msg,
token,
if is_initialize {
None
} else {
session_id.as_deref()
},
)
.await?;
let status = resp.status();
let can_reinit = { state.session.lock().await.init_request.is_some() };
if !is_initialize && (status == 404 || status == 400) && can_reinit {
let _ = resp.bytes().await;
reinitialize(state, token).await?;
let session_id = state.session.lock().await.id.clone();
let resp = post_message(state, msg, token, session_id.as_deref()).await?;
return consume_response(state, resp, msg, is_initialize, out).await;
}
consume_response(state, resp, msg, is_initialize, out).await
}
async fn post_message(
state: &ProxyState,
msg: &JsonValue,
token: &str,
session_id: Option<&str>,
) -> Result<reqwest::Response> {
let mut req = state
.http
.post(&state.url)
.header("authorization", format!("Bearer {token}"))
.header("accept", "application/json, text/event-stream")
.header("x-source", consts::get_user_agent());
if let Some(sid) = session_id {
req = req.header("mcp-session-id", sid);
}
req.json(msg)
.send()
.await
.context("failed to reach the remote MCP server")
}
async fn reinitialize(state: &ProxyState, token: &str) -> Result<()> {
let mut session = state.session.lock().await;
let init = session
.init_request
.clone()
.context("no initialize request captured yet")?;
let resp = post_message(state, &init, token, None).await?;
anyhow::ensure!(
resp.status().is_success(),
"re-initialize failed with HTTP {}",
resp.status()
);
session.id = resp
.headers()
.get("mcp-session-id")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let _ = resp.bytes().await;
let session_id = session.id.clone();
drop(session);
let initialized = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
let resp = post_message(state, &initialized, token, session_id.as_deref()).await?;
let _ = resp.bytes().await;
Ok(())
}
async fn consume_response(
state: &ProxyState,
resp: reqwest::Response,
msg: &JsonValue,
is_initialize: bool,
out: &Out,
) -> Result<()> {
let status = resp.status();
if is_initialize {
if let Some(sid) = resp
.headers()
.get("mcp-session-id")
.and_then(|v| v.to_str().ok())
{
state.session.lock().await.id = Some(sid.to_string());
}
}
if status == 401 || status == 403 {
let _ = resp.bytes().await;
for id in &ids_of(msg) {
send_error(
out,
id,
AUTH_ERROR_CODE,
"Railway rejected the CLI's credentials. Run `railway login` and try again.",
);
}
return Ok(());
}
if status == 202 || status == 204 {
return Ok(());
}
if !status.is_success() {
let body = read_body_capped(resp).await.unwrap_or_default();
anyhow::bail!(
"remote MCP server returned HTTP {status}: {}",
truncate(&body, 300)
);
}
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if content_type.starts_with("text/event-stream") {
stream_sse(resp, out).await
} else {
let body = read_body_capped(resp).await?;
emit_json_line(body.trim(), out);
Ok(())
}
}
async fn read_body_capped(resp: reqwest::Response) -> Result<String> {
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("error reading response from remote MCP server")?;
if buf.len() + chunk.len() > MAX_RESPONSE_BYTES {
anyhow::bail!(
"remote MCP server response exceeded {MAX_RESPONSE_BYTES} bytes; aborting."
);
}
buf.extend_from_slice(&chunk);
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
async fn stream_sse(resp: reqwest::Response, out: &Out) -> Result<()> {
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("error reading SSE stream from remote MCP server")?;
buf.extend_from_slice(&chunk);
while let Some((event_len, boundary_end)) = find_event_boundary(&buf) {
let event: Vec<u8> = buf.drain(..boundary_end).collect();
emit_sse_event(&event[..event_len], out);
}
if buf.len() > MAX_RESPONSE_BYTES {
anyhow::bail!(
"remote MCP server SSE event exceeded {MAX_RESPONSE_BYTES} bytes; aborting."
);
}
}
if !buf.is_empty() {
emit_sse_event(&buf, out);
}
Ok(())
}
fn find_event_boundary(buf: &[u8]) -> Option<(usize, usize)> {
for i in 0..buf.len() {
if buf[i] != b'\n' {
continue;
}
if buf.get(i + 1) == Some(&b'\n') {
return Some((i, i + 2));
}
if buf.get(i + 1) == Some(&b'\r') && buf.get(i + 2) == Some(&b'\n') {
return Some((i, i + 3));
}
}
None
}
fn emit_sse_event(raw: &[u8], out: &Out) {
let text = String::from_utf8_lossy(raw);
let data_lines: Vec<&str> = text
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(|rest| rest.strip_prefix(' ').unwrap_or(rest))
.collect();
if data_lines.is_empty() {
return;
}
emit_json_line(&data_lines.join("\n"), out);
}
fn emit_json_line(payload: &str, out: &Out) {
if payload.is_empty() {
return;
}
let line = serde_json::from_str::<JsonValue>(payload)
.map(|v| v.to_string())
.unwrap_or_else(|_| payload.replace(['\n', '\r'], " "));
let _ = out.send(line);
}
fn send_error(out: &Out, id: &JsonValue, code: i64, message: &str) {
let err = json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message },
});
let _ = out.send(err.to_string());
}
async fn end_session(state: &ProxyState) {
let session_id = state.session.lock().await.id.clone();
let Some(session_id) = session_id else { return };
let token = { state.configs.lock().await.get_railway_auth_token() };
let Some(token) = token else { return };
let _ = state
.http
.delete(&state.url)
.header("authorization", format!("Bearer {token}"))
.header("mcp-session-id", session_id)
.timeout(Duration::from_secs(5))
.send()
.await;
}
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let mut out: String = s.chars().take(max_chars).collect();
out.push('…');
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn collect(rx: &mut mpsc::UnboundedReceiver<String>) -> Vec<String> {
let mut out = Vec::new();
while let Ok(line) = rx.try_recv() {
out.push(line);
}
out
}
#[test]
fn mcp_override_rejects_plaintext_outside_dev() {
assert!(validate_mcp_override("http://evil.example/mcp", false).is_err());
assert_eq!(validate_mcp_override(" ", false).unwrap(), None);
assert_eq!(
validate_mcp_override("https://mcp.railway.com/", false).unwrap(),
Some("https://mcp.railway.com".to_string()),
);
assert_eq!(
validate_mcp_override("http://localhost:8080", true).unwrap(),
Some("http://localhost:8080".to_string()),
);
}
#[test]
fn sse_event_boundary_handles_lf_and_crlf() {
assert_eq!(find_event_boundary(b"data: {}\n\nrest"), Some((8, 10)));
assert_eq!(find_event_boundary(b"data: {}\r\n\r\nrest"), Some((9, 12)));
assert_eq!(find_event_boundary(b"data: {}"), None);
}
#[test]
fn sse_event_extracts_data_payload() {
let (tx, mut rx) = mpsc::unbounded_channel();
emit_sse_event(b"event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1}", &tx);
assert_eq!(collect(&mut rx), vec![r#"{"id":1,"jsonrpc":"2.0"}"#]);
}
#[test]
fn sse_event_joins_multiline_data() {
let (tx, mut rx) = mpsc::unbounded_channel();
emit_sse_event(b"data: {\"a\":\ndata: 1}", &tx);
assert_eq!(collect(&mut rx), vec![r#"{"a":1}"#]);
}
#[test]
fn sse_event_without_data_is_dropped() {
let (tx, mut rx) = mpsc::unbounded_channel();
emit_sse_event(b"event: ping\nid: 4", &tx);
assert!(collect(&mut rx).is_empty());
}
#[test]
fn json_lines_are_compacted_to_one_line() {
let (tx, mut rx) = mpsc::unbounded_channel();
emit_json_line("{\n \"jsonrpc\": \"2.0\",\n \"id\": 7\n}", &tx);
let lines = collect(&mut rx);
assert_eq!(lines.len(), 1);
assert!(!lines[0].contains('\n'));
}
#[test]
fn unauthenticated_initialize_fabricates_result() {
let (tx, mut rx) = mpsc::unbounded_channel();
let msg = json!({
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": { "protocolVersion": "2025-06-18" },
});
respond_unauthenticated(&msg, &ids_of(&msg), &tx);
let lines = collect(&mut rx);
assert_eq!(lines.len(), 1);
let parsed: JsonValue = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(
parsed.pointer("/result/protocolVersion").unwrap(),
"2025-06-18"
);
assert!(
parsed
.pointer("/result/instructions")
.unwrap()
.as_str()
.unwrap()
.contains("railway login")
);
}
#[test]
fn unauthenticated_request_gets_actionable_error() {
let (tx, mut rx) = mpsc::unbounded_channel();
let msg = json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/list" });
respond_unauthenticated(&msg, &ids_of(&msg), &tx);
let lines = collect(&mut rx);
assert_eq!(lines.len(), 1);
let parsed: JsonValue = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(parsed.pointer("/error/code").unwrap(), AUTH_ERROR_CODE);
assert!(
parsed
.pointer("/error/message")
.unwrap()
.as_str()
.unwrap()
.contains("railway login")
);
}
#[test]
fn unauthenticated_notification_is_dropped() {
let (tx, mut rx) = mpsc::unbounded_channel();
let msg = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
respond_unauthenticated(&msg, &ids_of(&msg), &tx);
assert!(collect(&mut rx).is_empty());
}
#[test]
fn unauthenticated_batch_answers_every_id() {
let (tx, mut rx) = mpsc::unbounded_channel();
let msg = json!([
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" },
{ "jsonrpc": "2.0", "method": "notifications/progress" },
{ "jsonrpc": "2.0", "id": "two", "method": "tools/call" },
]);
let ids = ids_of(&msg);
assert_eq!(ids, vec![json!(1), json!("two")]);
respond_unauthenticated(&msg, &ids, &tx);
let lines = collect(&mut rx);
assert_eq!(lines.len(), 2);
for line in &lines {
let parsed: JsonValue = serde_json::from_str(line).unwrap();
assert_eq!(parsed.pointer("/error/code").unwrap(), AUTH_ERROR_CODE);
}
}
}