use std::{fmt, path::PathBuf, sync::Arc};
use super::params::*;
use rmcp::{
ErrorData as McpError, RoleServer, ServerHandler,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::*,
service::RequestContext,
tool, tool_router,
};
use crate::{
client::post_graphql,
config::Configs,
controllers::{
deployment::{FetchLogsParams, fetch_build_logs, fetch_deploy_logs, fetch_http_logs},
environment::get_matched_environment,
project::{get_environment_instances, get_project, get_service_ids_in_env},
upload::{create_deploy_tarball, upload_deploy_tarball},
user::get_user,
variables::get_service_variables_including_sealed,
},
gql::{mutations, queries},
telemetry,
util::{
logs::{HttpLogLike, LogLike},
time::parse_time,
},
workspace::workspaces,
};
#[derive(Clone)]
pub struct RailwayMcp {
client: Arc<std::sync::RwLock<AuthedClient>>,
pub(crate) configs: Arc<Configs>,
tool_router: ToolRouter<Self>,
}
struct AuthedClient {
http: reqwest::Client,
token: Option<String>,
}
pub(crate) struct ResolvedContext {
pub(crate) project_id: String,
pub(crate) environment_id: String,
pub(crate) project: queries::RailwayProject,
pub(crate) linked: Option<crate::config::LinkedProject>,
}
pub(crate) struct ResolvedServiceContext {
pub(crate) project_id: String,
pub(crate) environment_id: String,
pub(crate) service_id: String,
pub(crate) context: ResolvedContext,
}
impl RailwayMcp {
pub fn new(client: reqwest::Client, configs: Configs) -> Self {
let token = configs.get_railway_auth_token();
Self {
client: Arc::new(std::sync::RwLock::new(AuthedClient {
http: client,
token,
})),
configs: Arc::new(configs),
tool_router: Self::tool_router(),
}
}
pub(crate) fn client(&self) -> reqwest::Client {
self.client
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.http
.clone()
}
pub(crate) async fn sync_client(&self, force: bool) -> bool {
let Ok(mut configs) = Configs::new() else {
return false;
};
if force {
let _ = crate::client::force_refresh(&mut configs).await;
} else {
let _ = crate::client::ensure_valid_token(&mut configs).await;
}
let token = configs.get_railway_auth_token();
if self
.client
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.token
== token
{
return false;
}
if token.is_none() {
return false;
}
match crate::client::GQLClient::new_authorized(&configs) {
Ok(http) => {
*self
.client
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
AuthedClient { http, token };
true
}
Err(_) => false,
}
}
pub(crate) async fn resolve_context(
&self,
project_id: Option<String>,
environment_id: Option<String>,
) -> Result<ResolvedContext, McpError> {
let local_linked = self.configs.get_local_linked_project().ok();
let token_linked = self.configs.get_linked_project().await.ok();
let linked = local_linked.or(token_linked);
let project_id = project_id
.or_else(|| linked.as_ref().map(|l| l.project.clone()))
.ok_or_else(|| {
McpError::invalid_params(
"No project_id provided and no linked project. Run 'railway link' or pass a project_id.",
None,
)
})?;
let project = get_project(&self.client(), &self.configs, project_id.clone())
.await
.map_err(|e| McpError::internal_error(format!("Failed to get project: {e}"), None))?;
let env_id_or_name = environment_id
.or_else(|| linked.as_ref().and_then(|l| l.environment.clone()))
.ok_or_else(|| {
let available = format_environments(&project);
McpError::invalid_params(
format!("No environment_id provided and no linked environment. Available environments:\n{available}"),
None,
)
})?;
let environment = get_matched_environment(&project, env_id_or_name).map_err(|e| {
let available = format_environments(&project);
McpError::invalid_params(
format!("Failed to resolve environment: {e}. Available environments:\n{available}"),
None,
)
})?;
Ok(ResolvedContext {
project_id,
environment_id: environment.id,
project,
linked,
})
}
pub(crate) async fn get_latest_deployment_id(
&self,
project_id: &str,
environment_id: &str,
service_id: &str,
) -> Result<String, McpError> {
let vars = queries::deployments::Variables {
input: queries::deployments::DeploymentListInput {
project_id: Some(project_id.to_owned()),
environment_id: Some(environment_id.to_owned()),
service_id: Some(service_id.to_owned()),
include_deleted: None,
status: None,
},
first: Some(1),
};
let response = post_graphql::<queries::Deployments, _>(
&self.client(),
self.configs.get_backboard(),
vars,
)
.await
.map_err(|e| McpError::internal_error(format!("Failed to fetch deployments: {e}"), None))?;
response
.deployments
.edges
.first()
.map(|e| e.node.id.clone())
.ok_or_else(|| {
McpError::internal_error("No deployments found for this service.".to_string(), None)
})
}
pub(crate) async fn resolve_service_context(
&self,
project_id: Option<String>,
service_id: Option<String>,
environment_id: Option<String>,
) -> Result<ResolvedServiceContext, McpError> {
let ctx = self.resolve_context(project_id, environment_id).await?;
let service_id = match service_id {
Some(sid) => ctx
.project
.services
.edges
.iter()
.find(|s| s.node.id == sid || s.node.name.eq_ignore_ascii_case(&sid))
.map(|s| s.node.id.clone())
.ok_or_else(|| {
let available = format_services(&ctx.project);
McpError::invalid_params(
format!("Service '{sid}' not found. Available services:\n{available}"),
None,
)
})?,
None => ctx
.linked
.as_ref()
.and_then(|l| l.service.clone())
.ok_or_else(|| {
let available = format_services(&ctx.project);
McpError::invalid_params(
format!("No service_id provided and no linked service. Available services:\n{available}"),
None,
)
})?,
};
Ok(ResolvedServiceContext {
project_id: ctx.project_id.clone(),
environment_id: ctx.environment_id.clone(),
service_id,
context: ctx,
})
}
pub(crate) async fn fetch_domains(
&self,
ctx: &ResolvedServiceContext,
) -> Result<queries::domains::DomainsDomains, McpError> {
post_graphql::<queries::Domains, _>(
&self.client(),
self.configs.get_backboard(),
queries::domains::Variables {
environment_id: ctx.environment_id.clone(),
project_id: ctx.project_id.clone(),
service_id: ctx.service_id.clone(),
},
)
.await
.map(|response| response.domains)
.map_err(|e| McpError::internal_error(format!("Failed to query domains: {e}"), None))
}
pub(crate) async fn resolve_domain_details(
&self,
ctx: &ResolvedServiceContext,
identifier: &str,
) -> Result<McpDomainDetails, McpError> {
let domains = self.fetch_domains(ctx).await?;
let items = mcp_domain_items(&domains);
find_mcp_domain(&items, identifier).cloned().ok_or_else(|| {
McpError::invalid_params(
format!("Domain '{identifier}' not found on the selected service."),
None,
)
})
}
}
fn format_environments(project: &queries::RailwayProject) -> String {
project
.environments
.edges
.iter()
.map(|e| format!("- {} (id: {})", e.node.name, e.node.id))
.collect::<Vec<_>>()
.join("\n")
}
fn format_services(project: &queries::RailwayProject) -> String {
project
.services
.edges
.iter()
.map(|s| format!("- {} (id: {})", s.node.name, s.node.id))
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum McpDomainKind {
Custom,
Service,
}
impl fmt::Display for McpDomainKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
McpDomainKind::Custom => write!(f, "custom"),
McpDomainKind::Service => write!(f, "service"),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct McpDomainDetails {
id: String,
domain: String,
kind: McpDomainKind,
target_port: Option<i64>,
sync_status: String,
service_domain_suffix: Option<String>,
environment_id: String,
service_id: String,
dns_records: Vec<McpDnsRecord>,
verification: Option<McpVerification>,
certificate: Option<McpCertificateStatus>,
}
#[derive(Debug, Clone)]
struct McpDnsRecord {
record_type: String,
name: String,
required_value: String,
current_value: String,
status: String,
zone: String,
}
#[derive(Debug, Clone)]
struct McpVerification {
verified: bool,
dns_host: Option<String>,
token: Option<String>,
}
#[derive(Debug, Clone)]
struct McpCertificateStatus {
status: String,
detailed_status: Option<String>,
error_message: Option<String>,
error_type: Option<String>,
retryable: Option<bool>,
cdn_provider: Option<String>,
}
fn mcp_domain_items(domains: &queries::domains::DomainsDomains) -> Vec<McpDomainDetails> {
domains
.service_domains
.iter()
.map(mcp_domain_from_service)
.chain(domains.custom_domains.iter().map(mcp_domain_from_custom))
.collect()
}
fn mcp_domain_from_service(
domain: &queries::domains::DomainsDomainsServiceDomains,
) -> McpDomainDetails {
McpDomainDetails {
id: domain.id.clone(),
domain: domain.domain.clone(),
kind: McpDomainKind::Service,
target_port: domain.target_port,
sync_status: enum_name(&domain.sync_status),
service_domain_suffix: domain.suffix.clone(),
environment_id: domain.environment_id.clone(),
service_id: domain.service_id.clone(),
dns_records: Vec::new(),
verification: None,
certificate: None,
}
}
fn mcp_domain_from_custom(
domain: &queries::domains::DomainsDomainsCustomDomains,
) -> McpDomainDetails {
McpDomainDetails {
id: domain.id.clone(),
domain: domain.domain.clone(),
kind: McpDomainKind::Custom,
target_port: domain.target_port,
sync_status: enum_name(&domain.sync_status),
service_domain_suffix: None,
environment_id: domain.environment_id.clone(),
service_id: domain.service_id.clone(),
dns_records: domain
.status
.dns_records
.iter()
.map(|record| McpDnsRecord {
record_type: enum_name(&record.record_type),
name: if record.hostlabel.is_empty() {
"@".to_string()
} else {
record.hostlabel.clone()
},
required_value: record.required_value.clone(),
current_value: record.current_value.clone(),
status: enum_name(&record.status),
zone: record.zone.clone(),
})
.collect(),
verification: Some(McpVerification {
verified: domain.status.verified,
dns_host: domain.status.verification_dns_host.clone(),
token: domain.status.verification_token.clone(),
}),
certificate: Some(McpCertificateStatus {
status: enum_name(&domain.status.certificate_status),
detailed_status: enum_name_option(&domain.status.certificate_status_detailed),
error_message: domain.status.certificate_error_message.clone(),
error_type: enum_name_option(&domain.status.certificate_error_type),
retryable: domain.status.certificate_retryable,
cdn_provider: enum_name_option(&domain.status.cdn_provider),
}),
}
}
fn find_mcp_domain<'a>(
domains: &'a [McpDomainDetails],
identifier: &str,
) -> Option<&'a McpDomainDetails> {
let normalized = normalize_domain_identifier(identifier);
domains.iter().find(|domain| {
domain.id.eq_ignore_ascii_case(identifier)
|| domain.domain.eq_ignore_ascii_case(&normalized)
})
}
fn normalize_domain_identifier(identifier: &str) -> String {
let trimmed = identifier.trim();
let without_scheme = trimmed
.strip_prefix("https://")
.or_else(|| trimmed.strip_prefix("http://"))
.unwrap_or(trimmed);
without_scheme
.split('/')
.next()
.unwrap_or(without_scheme)
.trim_end_matches('.')
.to_string()
}
fn format_domains(domains: &[McpDomainDetails]) -> String {
if domains.is_empty() {
return "No domains found.".to_string();
}
let mut output = String::from("## Domains\n");
for domain in domains {
output.push_str(&format!(
"- https://{} (type: {}, id: {}, target_port: {}, sync_status: {})\n",
domain.domain,
domain.kind,
domain.id,
format_target_port(domain.target_port),
domain.sync_status
));
}
output
}
fn format_domain_details(domain: &McpDomainDetails) -> String {
let mut output = format!(
"## Domain status\nURL: https://{}\nID: {}\nType: {}\nTarget port: {}\nSync status: {}\n",
domain.domain,
domain.id,
domain.kind,
format_target_port(domain.target_port),
domain.sync_status
);
if let Some(verification) = &domain.verification {
output.push_str(&format!(
"Verified: {}\n",
if verification.verified { "yes" } else { "no" }
));
}
if let Some(certificate) = &domain.certificate {
output.push_str(&format!("Certificate status: {}\n", certificate.status));
if let Some(detailed_status) = &certificate.detailed_status {
output.push_str(&format!("Certificate detail: {detailed_status}\n"));
}
if let Some(error_message) = &certificate.error_message {
output.push_str(&format!("Certificate error: {error_message}\n"));
}
if let Some(error_type) = &certificate.error_type {
output.push_str(&format!("Certificate error type: {error_type}\n"));
}
if let Some(retryable) = certificate.retryable {
output.push_str(&format!("Certificate retryable: {retryable}\n"));
}
if let Some(cdn_provider) = &certificate.cdn_provider {
output.push_str(&format!("CDN provider: {cdn_provider}\n"));
}
}
if !domain.dns_records.is_empty() {
output.push_str("\nDNS records:\n");
for record in &domain.dns_records {
output.push_str(&format!(
"- {} {} -> {} (status: {}, current: {})\n",
record.record_type,
record.name,
record.required_value,
record.status,
if record.current_value.is_empty() {
"-"
} else {
&record.current_value
}
));
}
if let Some(verification) = &domain.verification
&& !verification.verified
&& let (Some(host), Some(token)) = (&verification.dns_host, &verification.token)
{
let zone = domain
.dns_records
.first()
.map(|record| record.zone.as_str())
.unwrap_or("");
let host_label = host.strip_suffix(&format!(".{zone}")).unwrap_or(host);
output.push_str(&format!(
"- TXT {host_label} -> {} (verification)\n",
verification_txt_value(token)
));
}
}
output
}
fn verification_txt_value(token: &str) -> String {
let mut token = token;
while let Some(stripped) = token.strip_prefix("railway-verify=") {
token = stripped;
}
format!("railway-verify={token}")
}
fn format_target_port(port: Option<i64>) -> String {
port.map(|port| port.to_string())
.unwrap_or_else(|| "-".to_string())
}
fn mcp_service_domain_input(
domain: &McpDomainDetails,
new_domain: &str,
) -> Result<String, McpError> {
let normalized = normalize_domain_identifier(new_domain);
if normalized.is_empty() {
return Err(McpError::invalid_params(
"new_domain must not be empty.",
None,
));
}
if normalized.contains('.') {
return Ok(normalized);
}
let Some(suffix) = &domain.service_domain_suffix else {
return Err(McpError::invalid_params(
"Pass the full service domain because the current suffix could not be resolved.",
None,
));
};
Ok(format!("{normalized}.{suffix}"))
}
fn enum_name<T: fmt::Debug>(value: &T) -> String {
format!("{value:?}")
}
fn enum_name_option<T: fmt::Debug>(value: &Option<T>) -> Option<String> {
value.as_ref().map(enum_name)
}
const CERTIFICATE_STATUS_ISSUE_FAILED: &str = "CERTIFICATE_STATUS_TYPE_ISSUE_FAILED";
fn certificate_retry_unavailable_reason(domain: &McpDomainDetails) -> Option<String> {
let Some(certificate) = &domain.certificate else {
return Some(
"Certificate retry is only available after certificate issuance fails. Current status is unknown."
.to_string(),
);
};
if certificate.status != CERTIFICATE_STATUS_ISSUE_FAILED {
return Some(format!(
"Certificate retry is only available after certificate issuance fails. Current status: {}.",
certificate.status
));
}
if certificate.retryable == Some(false) {
return Some(
certificate
.error_message
.as_ref()
.map(|message| format!("Certificate retry is not available for this failure. {message}"))
.unwrap_or_else(|| {
"Certificate retry is not available for this failure. Check your DNS configuration or contact support."
.to_string()
}),
);
}
None
}
fn validate_domain_port(port: i64) -> Result<i64, McpError> {
if (1..=65535).contains(&port) {
Ok(port)
} else {
Err(McpError::invalid_params(
"port must be a number from 1 to 65535",
None,
))
}
}
fn is_auth_failure(result: &Result<CallToolResult, McpError>) -> bool {
let Err(err) = result else {
return false;
};
let rendered = err.to_string();
crate::errors::AUTH_FAILURE_MARKERS
.iter()
.any(|marker| rendered.contains(marker))
}
#[tool_router]
impl RailwayMcp {
#[tool(description = "Check Railway authentication status and return the current user")]
async fn whoami(&self) -> Result<CallToolResult, McpError> {
let user = get_user(&self.client(), &self.configs).await.map_err(|e| {
McpError::internal_error(
format!("Not authenticated. Run 'railway login' first. Error: {e}"),
None,
)
})?;
let name = user.name.unwrap_or_else(|| "Unknown".to_string());
let output = format!("Logged in as {} ({})", name, user.email);
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(
description = "List all projects in the user's Railway account, grouped by workspace. Returns project names and IDs."
)]
async fn list_projects(&self) -> Result<CallToolResult, McpError> {
let workspaces = workspaces()
.await
.map_err(|e| McpError::internal_error(format!("Failed to list projects: {e}"), None))?;
let mut output = String::new();
for ws in &workspaces {
output.push_str(&format!("## {}\n", ws.name()));
for project in ws.projects() {
output.push_str(&format!("- {} (id: {})\n", project.name(), project.id()));
}
output.push('\n');
}
if output.is_empty() {
output = "No projects found.".to_string();
}
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(
description = "List all Railway workspaces available to the current user. Returns workspace names, IDs, team IDs, and project counts. Use the workspace ID with create_project."
)]
async fn list_workspaces(&self) -> Result<CallToolResult, McpError> {
let workspaces = workspaces().await.map_err(|e| {
McpError::internal_error(format!("Failed to list workspaces: {e}"), None)
})?;
if workspaces.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No workspaces found.".to_string(),
)]));
}
let output = workspaces
.iter()
.map(|workspace| {
let kind = if workspace.team_id().is_some() {
"team"
} else {
"personal"
};
let team_id = workspace.team_id().unwrap_or("none");
format!(
"- {} (workspace_id: {}, type: {}, team_id: {}, projects: {})",
workspace.name(),
workspace.id(),
kind,
team_id,
workspace.projects().len()
)
})
.collect::<Vec<_>>()
.join("\n");
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(
description = "List all services in a Railway project. If no project_id is provided, uses the currently linked project."
)]
async fn list_services(
&self,
Parameters(params): Parameters<ProjectParams>,
) -> Result<CallToolResult, McpError> {
let project_id = match params.project_id {
Some(id) => id,
None => {
let linked = self.configs.get_linked_project().await.map_err(|e| {
McpError::internal_error(
format!("No linked project and no project_id provided. Run 'railway link' or pass a project_id. Error: {e}"),
None,
)
})?;
linked.project
}
};
let project = get_project(&self.client(), &self.configs, project_id)
.await
.map_err(|e| McpError::internal_error(format!("Failed to get project: {e}"), None))?;
let mut output = format!("## Services in project: {}\n\n", project.name);
for edge in &project.services.edges {
output.push_str(&format!("- {} (id: {})\n", edge.node.name, edge.node.id));
}
if project.services.edges.is_empty() {
output.push_str("No services found.\n");
}
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(
description = "List recent deployments for a service. Returns deployment IDs, status, timestamps, and commit hashes. If no IDs are provided, uses the currently linked project/service/environment."
)]
async fn list_deployments(
&self,
Parameters(params): Parameters<ListDeploymentsParams>,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let limit = params.limit.unwrap_or(20);
let vars = queries::deployments::Variables {
input: queries::deployments::DeploymentListInput {
project_id: Some(ctx.project_id),
environment_id: Some(ctx.environment_id),
service_id: Some(ctx.service_id),
include_deleted: None,
status: None,
},
first: Some(limit),
};
let response = post_graphql::<queries::Deployments, _>(
&self.client(),
self.configs.get_backboard(),
vars,
)
.await
.map_err(|e| McpError::internal_error(format!("Failed to fetch deployments: {e}"), None))?;
let mut output = String::new();
for edge in &response.deployments.edges {
let node = &edge.node;
let commit = node
.meta
.as_ref()
.and_then(|m| m.get("commitHash"))
.and_then(|v| v.as_str())
.unwrap_or("-");
output.push_str(&format!(
"{} | {:?} | {} | {}\n",
node.id, node.status, node.created_at, commit
));
}
if output.is_empty() {
output = "No deployments found.".to_string();
}
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(
description = "List all environment variables for a service. Returns KEY=VALUE pairs. Sealed variables are listed by name only, in a trailing 'Sealed' section: they are already set on the service and their value cannot be read back by anyone, so treat them as present and never recreate them. If no IDs are provided, uses the currently linked project/service/environment."
)]
async fn list_variables(
&self,
Parameters(params): Parameters<ServiceParams>,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let variables = get_service_variables_including_sealed(
&self.client(),
&self.configs,
ctx.project_id,
ctx.environment_id,
ctx.service_id,
)
.await
.map_err(|e| McpError::internal_error(format!("Failed to fetch variables: {e}"), None))?;
if variables.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No variables found.",
)]));
}
let mut readable: Vec<String> = Vec::new();
let mut sealed: Vec<String> = Vec::new();
for (key, value) in &variables {
match value {
Some(value) => readable.push(format!("{key}={value}")),
None => sealed.push(format!("- {key}")),
}
}
let mut output = readable.join("\n");
if !sealed.is_empty() {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(
"Sealed (set on this service, but the value cannot be read back by anyone - treat these as present and do not recreate them):\n",
);
output.push_str(&sealed.join("\n"));
}
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(
description = "Get build, deploy, or HTTP logs for a service's deployment. Set log_type to 'build', 'deploy', or 'http'. Supports filtering by level/search for build/deploy logs, and method/status/path/request_id for HTTP logs. If no deployment_id is provided, uses the latest deployment."
)]
async fn get_logs(
&self,
Parameters(params): Parameters<GetLogsParams>,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let deployment_id = match params.deployment_id {
Some(did) => did,
None => {
self.get_latest_deployment_id(&ctx.project_id, &ctx.environment_id, &ctx.service_id)
.await?
}
};
let log_type = params.log_type.unwrap_or_default();
let filter = {
let mut parts = Vec::new();
match log_type {
LogType::Http => {
if let Some(ref method) = params.method {
parts.push(format!("@method:{method}"));
}
if let Some(ref status) = params.status {
parts.push(format!("@httpStatus:{status}"));
}
if let Some(ref path) = params.path {
parts.push(format!("@path:{path}"));
}
if let Some(ref request_id) = params.request_id {
parts.push(format!("@requestId:{request_id}"));
}
}
_ => {
if let Some(ref level) = params.level {
parts.push(format!("@level:{level}"));
}
}
}
if let Some(ref search) = params.search {
parts.push(search.clone());
}
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
};
let start_date = params
.since
.as_deref()
.map(parse_time)
.transpose()
.map_err(|e| McpError::invalid_params(format!("Invalid 'since' time: {e}"), None))?;
let end_date = params
.until
.as_deref()
.map(parse_time)
.transpose()
.map_err(|e| McpError::invalid_params(format!("Invalid 'until' time: {e}"), None))?;
let lines = params.lines.unwrap_or(100);
let backboard = self.configs.get_backboard();
let fetch_params = FetchLogsParams {
client: &self.client(),
backboard: &backboard,
deployment_id: deployment_id.clone(),
limit: Some(lines),
filter,
start_date,
end_date,
};
let mut logs = Vec::<String>::new();
match log_type {
LogType::Build => {
fetch_build_logs(fetch_params, |log| {
let line = format!("[{}] {}", log.timestamp(), log.message());
logs.push(line);
})
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to fetch build logs: {e}"), None)
})?;
}
LogType::Http => {
fetch_http_logs(fetch_params, |log| {
let line = format!(
"[{}] {} {} {} {}ms",
log.timestamp(),
log.method(),
log.path(),
log.http_status(),
log.total_duration(),
);
logs.push(line);
})
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to fetch HTTP logs: {e}"), None)
})?;
}
LogType::Deploy => {
fetch_deploy_logs(fetch_params, |log| {
let line = format!("[{}] {}", log.timestamp(), log.message());
logs.push(line);
})
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to fetch deploy logs: {e}"), None)
})?;
}
}
let output = logs.join("\n");
if output.is_empty() {
Ok(CallToolResult::success(vec![Content::text(format!(
"No {log_type:?} logs found for deployment {deployment_id}"
))]))
} else {
Ok(CallToolResult::success(vec![Content::text(output)]))
}
}
#[tool(
description = "Set one or more environment variables on a service. Pass variables as a JSON object mapping names to values. Triggers a redeploy unless skip_deploys is true."
)]
async fn set_variables(
&self,
Parameters(params): Parameters<SetVariablesParams>,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let keys: Vec<String> = params.variables.keys().cloned().collect();
let vars = mutations::variable_collection_upsert::Variables {
project_id: ctx.project_id,
service_id: ctx.service_id,
environment_id: ctx.environment_id,
variables: params.variables,
skip_deploys: params.skip_deploys,
};
post_graphql::<mutations::VariableCollectionUpsert, _>(
&self.client(),
self.configs.get_backboard(),
vars,
)
.await
.map_err(|e| McpError::internal_error(format!("Failed to set variables: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Successfully set {} variable(s): {}",
keys.len(),
keys.join(", ")
))]))
}
#[tool(
description = "Add a domain to a service. If 'domain' is provided, creates a custom domain and returns required DNS records. If omitted, generates a Railway service domain (or returns existing domains)."
)]
async fn generate_domain(
&self,
Parameters(params): Parameters<GenerateDomainParams>,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let target_port = params.port.map(validate_domain_port).transpose()?;
if let Some(custom_domain) = ¶ms.domain {
let create_vars = mutations::custom_domain_create::Variables {
input: mutations::custom_domain_create::CustomDomainCreateInput {
domain: custom_domain.clone(),
environment_id: ctx.environment_id.clone(),
project_id: ctx.project_id.clone(),
service_id: ctx.service_id.clone(),
target_port,
},
};
let result = post_graphql::<mutations::CustomDomainCreate, _>(
&self.client(),
self.configs.get_backboard(),
create_vars,
)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to create custom domain: {e}"), None)
})?;
let domain = self
.resolve_domain_details(&ctx, &result.custom_domain_create.id)
.await?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Custom domain created.\n\n{}",
format_domain_details(&domain)
))]))
} else {
let existing = self.fetch_domains(&ctx).await?;
let domains = &existing;
if !domains.service_domains.is_empty() || !domains.custom_domains.is_empty() {
let output = format!(
"Existing domains:\n{}",
format_domains(&mcp_domain_items(domains))
);
return Ok(CallToolResult::success(vec![Content::text(output)]));
}
let create_vars = mutations::service_domain_create::Variables {
environment_id: ctx.environment_id.clone(),
service_id: ctx.service_id.clone(),
target_port,
};
let result = post_graphql::<mutations::ServiceDomainCreate, _>(
&self.client(),
self.configs.get_backboard(),
create_vars,
)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to create service domain: {e}"), None)
})?;
let domain = self
.resolve_domain_details(&ctx, &result.service_domain_create.id)
.await?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Service domain created.\n\n{}",
format_domain_details(&domain)
))]))
}
}
#[tool(
description = "List service and custom domains for a service. Returns domain, type, ID, target port, and sync status."
)]
async fn list_domains(
&self,
Parameters(params): Parameters<ServiceParams>,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let domains = self.fetch_domains(&ctx).await?;
Ok(CallToolResult::success(vec![Content::text(
format_domains(&mcp_domain_items(&domains)),
)]))
}
#[tool(
description = "Show status for a service or custom domain by domain name, URL, or domain ID. Includes DNS records, verification status, certificate status/errors, sync status, and target port."
)]
async fn domain_status(
&self,
Parameters(params): Parameters<DomainStatusParams>,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let domain = self.resolve_domain_details(&ctx, ¶ms.domain).await?;
Ok(CallToolResult::success(vec![Content::text(
format_domain_details(&domain),
)]))
}
#[tool(
description = "Retry failed TLS certificate issuance for a custom domain by domain name, URL, or domain ID. Custom domains only. Returns updated status after readback."
)]
async fn retry_domain_certificate(
&self,
Parameters(params): Parameters<DomainStatusParams>,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let domain = self.resolve_domain_details(&ctx, ¶ms.domain).await?;
if domain.kind != McpDomainKind::Custom {
return Err(McpError::invalid_params(
"Certificate retry is only supported for custom domains.",
None,
));
}
if let Some(reason) = certificate_retry_unavailable_reason(&domain) {
return Err(McpError::invalid_params(reason, None));
}
post_graphql::<mutations::CustomDomainIssueCertificate, _>(
&self.client(),
self.configs.get_backboard(),
mutations::custom_domain_issue_certificate::Variables {
id: domain.id.clone(),
},
)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to retry domain certificate: {e}"), None)
})?;
let updated = self.resolve_domain_details(&ctx, &domain.id).await?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Certificate retry requested.\n\n{}",
format_domain_details(&updated)
))]))
}
#[tool(
description = "Delete a custom or service domain by domain name, URL, or domain ID. This is irreversible. Returns a preview first.",
annotations(destructive_hint = true)
)]
async fn delete_domain(
&self,
Parameters(params): Parameters<DeleteDomainParams>,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let domain = self.resolve_domain_details(&ctx, ¶ms.domain).await?;
if !params.confirm {
return Ok(CallToolResult::success(vec![Content::text(format!(
"⚠️ This will permanently delete {} domain https://{} (id: {}). Call again with confirm: true to proceed.",
domain.kind, domain.domain, domain.id
))]));
}
match domain.kind {
McpDomainKind::Custom => {
post_graphql::<mutations::CustomDomainDelete, _>(
&self.client(),
self.configs.get_backboard(),
mutations::custom_domain_delete::Variables {
id: domain.id.clone(),
},
)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to delete custom domain: {e}"), None)
})?;
}
McpDomainKind::Service => {
post_graphql::<mutations::ServiceDomainDelete, _>(
&self.client(),
self.configs.get_backboard(),
mutations::service_domain_delete::Variables {
id: domain.id.clone(),
},
)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to delete service domain: {e}"), None)
})?;
}
}
let domains = self.fetch_domains(&ctx).await?;
let remaining = mcp_domain_items(&domains);
if find_mcp_domain(&remaining, &domain.id).is_some() {
return Err(McpError::internal_error(
format!(
"Domain deletion was requested, but {} still exists after verification.",
domain.id
),
None,
));
}
Ok(CallToolResult::success(vec![Content::text(format!(
"Deleted {} domain https://{} (id: {}).",
domain.kind, domain.domain, domain.id
))]))
}
#[tool(
description = "Update a custom or service domain by domain name, URL, or domain ID. Supports target port changes for both domain types and Railway service-domain renames via new_domain. Port must be from 1 to 65535. Returns updated status after readback."
)]
async fn update_domain(
&self,
Parameters(params): Parameters<UpdateDomainParams>,
) -> Result<CallToolResult, McpError> {
if params.port.is_none() && params.new_domain.is_none() {
return Err(McpError::invalid_params(
"Provide port, new_domain, or both.",
None,
));
}
let port = params.port.map(validate_domain_port).transpose()?;
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let domain = self.resolve_domain_details(&ctx, ¶ms.domain).await?;
match domain.kind {
McpDomainKind::Custom => {
if params.new_domain.is_some() {
return Err(McpError::invalid_params(
"Custom domains cannot be renamed. Create the new custom domain, then delete the old one.",
None,
));
}
post_graphql::<mutations::CustomDomainUpdate, _>(
&self.client(),
self.configs.get_backboard(),
mutations::custom_domain_update::Variables {
environment_id: domain.environment_id.clone(),
id: domain.id.clone(),
target_port: port,
},
)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to update custom domain: {e}"), None)
})?;
}
McpDomainKind::Service => {
post_graphql::<mutations::ServiceDomainUpdate, _>(
&self.client(),
self.configs.get_backboard(),
mutations::service_domain_update::Variables {
input: mutations::service_domain_update::ServiceDomainUpdateInput {
domain: params
.new_domain
.as_deref()
.map(|new_domain| mcp_service_domain_input(&domain, new_domain))
.transpose()?
.unwrap_or_else(|| domain.domain.clone()),
environment_id: domain.environment_id.clone(),
service_domain_id: domain.id.clone(),
service_id: domain.service_id.clone(),
target_port: port,
},
},
)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to update service domain: {e}"), None)
})?;
}
}
let updated = self.resolve_domain_details(&ctx, &domain.id).await?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Updated domain https://{}.\n\n{}",
domain.domain,
format_domain_details(&updated)
))]))
}
#[tool(
description = "Link a service to the current project directory for the CLI. If no service_id or service_name is provided, lists available services. Uses a fresh config to write the link."
)]
async fn link_service(
&self,
Parameters(params): Parameters<LinkServiceParams>,
) -> Result<CallToolResult, McpError> {
let linked = self.configs.get_linked_project().await.ok();
let requested_project_id = params.project_id.clone();
let project_id = params
.project_id
.or_else(|| linked.as_ref().map(|l| l.project.clone()))
.ok_or_else(|| {
McpError::invalid_params(
"No project_id provided and no linked project. Run 'railway link' first.",
None,
)
})?;
let project = get_project(&self.client(), &self.configs, project_id.clone())
.await
.map_err(|e| McpError::internal_error(format!("Failed to get project: {e}"), None))?;
let environment_id = linked
.as_ref()
.filter(|l| {
requested_project_id
.as_ref()
.is_none_or(|requested| requested == &l.project)
})
.and_then(|l| l.environment.as_deref());
let env_service_ids = if let Some(eid) = environment_id {
let environment = get_matched_environment(&project, eid.to_string()).map_err(|e| {
let available = format_environments(&project);
McpError::invalid_params(
format!(
"Failed to resolve environment: {e}. Available environments:\n{available}"
),
None,
)
})?;
let instances = get_environment_instances(
&self.client(),
&self.configs,
&project_id,
&environment.id,
)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to get environment instances: {e}"), None)
})?;
Some(get_service_ids_in_env(&instances))
} else {
None
};
let services_in_env: Vec<_> = project
.services
.edges
.iter()
.filter(|s| {
env_service_ids
.as_ref()
.is_none_or(|ids| ids.contains(&s.node.id))
})
.collect();
if params.service_id.is_none() && params.service_name.is_none() {
let available: Vec<String> = services_in_env
.iter()
.map(|s| format!("- {} (id: {})", s.node.name, s.node.id))
.collect();
let available = if available.is_empty() {
"No services found in this environment.".to_string()
} else {
available.join("\n")
};
return Ok(CallToolResult::success(vec![Content::text(format!(
"Available services in project '{}':\n{available}",
project.name
))]));
}
let service = if let Some(sid) = ¶ms.service_id {
services_in_env
.iter()
.find(|s| s.node.id == *sid || s.node.name.eq_ignore_ascii_case(sid))
.ok_or_else(|| {
McpError::invalid_params(
format!("Service '{sid}' not found in the current environment."),
None,
)
})?
} else {
let name = params.service_name.as_ref().unwrap();
services_in_env
.iter()
.find(|s| s.node.name.eq_ignore_ascii_case(name))
.ok_or_else(|| {
McpError::invalid_params(
format!("Service '{name}' not found in the current environment."),
None,
)
})?
};
let mut configs = Configs::new()
.map_err(|e| McpError::internal_error(format!("Failed to create config: {e}"), None))?;
configs
.link_service(service.node.id.clone())
.map_err(|e| McpError::internal_error(format!("Failed to link service: {e}"), None))?;
configs
.write()
.map_err(|e| McpError::internal_error(format!("Failed to write config: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Linked service '{}' (id: {})",
service.node.name, service.node.id
))]))
}
#[tool(
description = "Switch the linked environment for the current project directory. If no environment_id or environment_name is provided, lists available environments. Preserves the existing service link."
)]
async fn link_environment(
&self,
Parameters(params): Parameters<LinkEnvironmentParams>,
) -> Result<CallToolResult, McpError> {
let local_linked = self.configs.get_local_linked_project().ok();
let token_linked = self.configs.get_linked_project().await.ok();
let linked = local_linked.or(token_linked);
let project_id = linked.as_ref().map(|l| l.project.clone()).ok_or_else(|| {
McpError::invalid_params(
"No linked project. Run 'railway link' first or use link_service.",
None,
)
})?;
let project = get_project(&self.client(), &self.configs, project_id.clone())
.await
.map_err(|e| McpError::internal_error(format!("Failed to get project: {e}"), None))?;
if params.environment_id.is_none() && params.environment_name.is_none() {
let available = format_environments(&project);
return Ok(CallToolResult::success(vec![Content::text(format!(
"Available environments in project '{}':\n{available}",
project.name
))]));
}
let env_identifier = params.environment_id.or(params.environment_name).unwrap();
let environment =
get_matched_environment(&project, env_identifier.clone()).map_err(|e| {
let available = format_environments(&project);
McpError::invalid_params(
format!("Environment not found: {e}. Available:\n{available}"),
None,
)
})?;
let mut configs = Configs::new()
.map_err(|e| McpError::internal_error(format!("Failed to create config: {e}"), None))?;
configs
.link_project(
project_id,
Some(project.name.clone()),
environment.id.clone(),
Some(environment.name.clone()),
)
.map_err(|e| {
McpError::internal_error(format!("Failed to link environment: {e}"), None)
})?;
configs
.write()
.map_err(|e| McpError::internal_error(format!("Failed to write config: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Linked environment '{}' (id: {}). Service link cleared — use link_service to re-link.",
environment.name, environment.id
))]))
}
#[tool(
description = "Create a new Railway project. Returns the project ID and the default environment ID."
)]
async fn create_project(
&self,
Parameters(params): Parameters<CreateProjectParams>,
) -> Result<CallToolResult, McpError> {
self.do_create_project(params).await
}
#[tool(
description = "Create a new environment in a Railway project. Optionally fork from an existing environment."
)]
async fn create_environment(
&self,
Parameters(params): Parameters<CreateEnvironmentParams>,
) -> Result<CallToolResult, McpError> {
self.do_create_environment(params).await
}
#[tool(
description = "Create a new service in a Railway project. Optionally connect a GitHub repo or Docker image."
)]
async fn create_service(
&self,
Parameters(params): Parameters<CreateServiceParams>,
) -> Result<CallToolResult, McpError> {
self.do_create_service(params).await
}
#[tool(
description = "Connect an existing Railway service to a GitHub repo or Docker image. For GitHub repos, this enables Railway-managed deployment triggers when the project has GitHub App access."
)]
async fn connect_service_source(
&self,
Parameters(params): Parameters<ConnectServiceSourceParams>,
) -> Result<CallToolResult, McpError> {
self.do_connect_service_source(params).await
}
#[tool(description = "Disconnect an existing Railway service from its current source.")]
async fn disconnect_service_source(
&self,
Parameters(params): Parameters<ServiceParams>,
) -> Result<CallToolResult, McpError> {
self.do_disconnect_service_source(params).await
}
#[tool(
description = "Remove a service from a Railway project. This is irreversible. Returns a preview first.",
annotations(destructive_hint = true)
)]
async fn remove_service(
&self,
Parameters(params): Parameters<RemoveServiceParams>,
) -> Result<CallToolResult, McpError> {
if !params.confirm {
return Ok(CallToolResult::success(vec![Content::text(
"⚠️ This will permanently delete the service and all its deployments. Call again with confirm: true to proceed.",
)]));
}
self.do_remove_service(params).await
}
#[tool(
description = "Update service instance settings such as build command, start command, health check, sleep mode, root directory, cron schedule, Dockerfile path, restart policy, pre-deploy command, Railway config file, and watch patterns. Use scale_service for replicas and regions."
)]
async fn update_service(
&self,
Parameters(params): Parameters<UpdateServiceParams>,
) -> Result<CallToolResult, McpError> {
self.do_update_service(params).await
}
#[tool(
description = "Scale one service across Railway deploy regions using friendly region names or region IDs. Provide replicas as a map, e.g. {\"eu-west\": 2, \"us-east\": 1}. Maximum 50 total replicas across regions."
)]
async fn scale_service(
&self,
Parameters(params): Parameters<ScaleServiceParams>,
) -> Result<CallToolResult, McpError> {
self.do_scale_service(params).await
}
#[tool(
description = "Get the deployment status of all services in a Railway environment. Returns a table of service name, status, replica count, and latest deploy time."
)]
async fn environment_status(
&self,
Parameters(params): Parameters<EnvironmentStatusParams>,
) -> Result<CallToolResult, McpError> {
self.do_environment_status(params).await
}
#[tool(
description = "Get the current configuration of a service instance including source, build config, start command, and variable count."
)]
async fn get_service_config(
&self,
Parameters(params): Parameters<GetServiceConfigParams>,
) -> Result<CallToolResult, McpError> {
self.do_get_service_config(params).await
}
#[tool(
description = "List public TCP proxies for a Railway service. Returns endpoint, ID, proxy port, application port, and sync status."
)]
async fn list_tcp_proxies(
&self,
Parameters(params): Parameters<ServiceParams>,
) -> Result<CallToolResult, McpError> {
self.do_list_tcp_proxies(params).await
}
#[tool(
description = "Create a public TCP proxy for a Railway service application port. Only one TCP proxy is allowed per service instance."
)]
async fn create_tcp_proxy(
&self,
Parameters(params): Parameters<CreateTcpProxyParams>,
) -> Result<CallToolResult, McpError> {
self.do_create_tcp_proxy(params).await
}
#[tool(
description = "Get details for one public TCP proxy by ID, domain, endpoint, proxy port, or application port."
)]
async fn get_tcp_proxy(
&self,
Parameters(params): Parameters<TcpProxySelectorParams>,
) -> Result<CallToolResult, McpError> {
self.do_get_tcp_proxy(params).await
}
#[tool(
description = "Remove a public TCP proxy by ID, domain, endpoint, proxy port, or application port. This is irreversible and returns a preview first.",
annotations(destructive_hint = true)
)]
async fn remove_tcp_proxy(
&self,
Parameters(params): Parameters<RemoveTcpProxyParams>,
) -> Result<CallToolResult, McpError> {
self.do_remove_tcp_proxy(params).await
}
#[tool(
description = "Get private networking status for a service. Returns full hostname, short name, network name/ID, sync status, address family, and private IPs. If network is omitted, returns all private networks in the environment."
)]
async fn private_network_status(
&self,
Parameters(params): Parameters<PrivateNetworkParams>,
) -> Result<CallToolResult, McpError> {
self.do_private_network_status(params).await
}
#[tool(
description = "Update the private networking endpoint name for a service. Name must be the short prefix without the .internal suffix. If network is omitted, uses the only private network or the network named railway."
)]
async fn private_network_update(
&self,
Parameters(params): Parameters<UpdatePrivateNetworkParams>,
) -> Result<CallToolResult, McpError> {
self.do_private_network_update(params).await
}
#[tool(
description = "Set reference variables on a service. Each variable value must be a Railway reference expression starting with '${{' (e.g. '${{ Postgres.DATABASE_URL }}')."
)]
async fn add_reference_variable(
&self,
Parameters(params): Parameters<AddReferenceVariableParams>,
) -> Result<CallToolResult, McpError> {
self.do_add_reference_variable(params).await
}
#[tool(
description = "Deploy a Railway template by its code (e.g. 'postgres', 'redis'). Returns the workflow ID to track deployment progress."
)]
async fn deploy_template(
&self,
Parameters(params): Parameters<DeployTemplateParams>,
) -> Result<CallToolResult, McpError> {
self.do_deploy_template(params).await
}
#[tool(
description = "Search for Railway templates using Railway's backend-ranked template search. Returns matching templates with their codes."
)]
async fn search_templates(
&self,
Parameters(params): Parameters<SearchTemplatesParams>,
) -> Result<CallToolResult, McpError> {
self.do_search_templates(params).await
}
#[tool(
description = "Get CPU and memory (or other) metrics for a service. Returns recent data points and average values for the specified time window."
)]
async fn service_metrics(
&self,
Parameters(params): Parameters<ServiceMetricsParams>,
) -> Result<CallToolResult, McpError> {
self.do_service_metrics(params).await
}
#[tool(
description = "Get HTTP request counts grouped by status code bucket (2xx/3xx/4xx/5xx) from recent HTTP logs."
)]
async fn http_requests(
&self,
Parameters(params): Parameters<HttpObservabilityParams>,
) -> Result<CallToolResult, McpError> {
self.do_http_requests(params).await
}
#[tool(
description = "Get the HTTP error rate (4xx + 5xx) as a percentage of total requests from recent HTTP logs."
)]
async fn http_error_rate(
&self,
Parameters(params): Parameters<HttpObservabilityParams>,
) -> Result<CallToolResult, McpError> {
self.do_http_error_rate(params).await
}
#[tool(
description = "Get HTTP response time percentiles (p50/p90/p95/p99) in milliseconds from recent HTTP logs."
)]
async fn http_response_time(
&self,
Parameters(params): Parameters<HttpObservabilityParams>,
) -> Result<CallToolResult, McpError> {
self.do_http_response_time(params).await
}
#[tool(
description = "Create a new object storage bucket in a Railway environment. Default region is sjc. Returns the bucket ID and name."
)]
async fn create_bucket(
&self,
Parameters(params): Parameters<CreateBucketParams>,
) -> Result<CallToolResult, McpError> {
self.do_create_bucket(params).await
}
#[tool(
description = "Remove an object storage bucket from a Railway environment. This is irreversible. Returns a preview first.",
annotations(destructive_hint = true)
)]
async fn remove_bucket(
&self,
Parameters(params): Parameters<RemoveBucketParams>,
) -> Result<CallToolResult, McpError> {
if !params.confirm {
return Ok(CallToolResult::success(vec![Content::text(format!(
"⚠️ This will permanently delete bucket '{}'. Call again with confirm: true to proceed.",
params.bucket_id
))]));
}
self.do_remove_bucket(params).await
}
#[tool(
description = "Create a persistent volume and attach it to a service at the given mount path."
)]
async fn create_volume(
&self,
Parameters(params): Parameters<CreateVolumeParams>,
) -> Result<CallToolResult, McpError> {
self.do_create_volume(params).await
}
#[tool(
description = "Update a volume's name or mount path. Provide environment_id and service_id when updating mount_path."
)]
async fn update_volume(
&self,
Parameters(params): Parameters<UpdateVolumeParams>,
) -> Result<CallToolResult, McpError> {
self.do_update_volume(params).await
}
#[tool(
description = "Remove a persistent volume by ID. This is irreversible. Returns a preview first.",
annotations(destructive_hint = true)
)]
async fn remove_volume(
&self,
Parameters(params): Parameters<RemoveVolumeParams>,
) -> Result<CallToolResult, McpError> {
if !params.confirm {
return Ok(CallToolResult::success(vec![Content::text(format!(
"⚠️ This will permanently delete volume '{}'. Call again with confirm: true to proceed.",
params.volume_id
))]));
}
self.do_remove_volume(params).await
}
#[tool(
description = "Search Railway documentation by keyword. Returns a list of matching page URLs. Use docs_fetch to read the full content of a specific page."
)]
async fn docs_search(
&self,
Parameters(params): Parameters<DocsSearchParams>,
) -> Result<CallToolResult, McpError> {
self.do_docs_search(params).await
}
#[tool(
description = "Fetch the full markdown content of a Railway documentation page. Accepts a docs URL (e.g. https://docs.railway.com/guides/getting-started) or a slug (e.g. guides/getting-started). Use docs_search first to find the right page."
)]
async fn docs_fetch(
&self,
Parameters(params): Parameters<DocsFetchParams>,
) -> Result<CallToolResult, McpError> {
self.do_docs_fetch(params).await
}
#[tool(
description = "Deploy code from a directory to Railway. Creates a tarball, uploads it, and starts a deployment. Returns the deployment ID and URLs. Use get_logs to monitor progress."
)]
async fn deploy(
&self,
Parameters(params): Parameters<DeployParams>,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_context(params.project_id, params.environment_id)
.await?;
let service_id = match ¶ms.service_id {
Some(sid) => {
let resolved = ctx
.project
.services
.edges
.iter()
.find(|s| s.node.id == *sid || s.node.name.eq_ignore_ascii_case(sid))
.map(|s| s.node.id.clone())
.ok_or_else(|| {
let available = format_services(&ctx.project);
McpError::invalid_params(
format!("Service '{sid}' not found. Available services:\n{available}"),
None,
)
})?;
Some(resolved)
}
None => self
.configs
.get_local_linked_project()
.ok()
.and_then(|l| l.service)
.or(ctx.linked.as_ref().and_then(|l| l.service.clone())),
};
let path = match ¶ms.path {
Some(p) => PathBuf::from(p),
None => std::env::current_dir().map_err(|e| {
McpError::internal_error(format!("Failed to get current directory: {e}"), None)
})?,
};
let body = create_deploy_tarball(&path, &path, false, |_, _| {}).map_err(|e| {
McpError::internal_error(format!("Failed to create deploy tarball: {e}"), None)
})?;
let hostname = self.configs.get_host();
let response = upload_deploy_tarball(
&self.client(),
hostname,
&ctx.project_id,
&ctx.environment_id,
service_id.as_deref(),
params.message.as_deref(),
body,
)
.await
.map_err(|e| McpError::internal_error(format!("Failed to deploy: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Deployment started!\n Deployment ID: {}\n URL: {}\n Build Logs: {}\n Domain: {}\n\nUse get_logs with deployment_id '{}' to check build/deploy progress.",
response.deployment_id,
response.url,
response.logs_url,
response.deployment_domain,
response.deployment_id,
))]))
}
}
fn railway_mcp_server_info() -> ServerInfo {
let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("railway", env!("CARGO_PKG_VERSION")))
.with_instructions(
"Railway MCP server. Manage your Railway projects, services, deployments, and more.",
);
info.protocol_version = ProtocolVersion::V_2025_03_26;
info
}
impl ServerHandler for RailwayMcp {
async fn initialize(
&self,
request: InitializeRequestParams,
context: RequestContext<RoleServer>,
) -> Result<InitializeResult, McpError> {
telemetry::record_mcp_client(&telemetry::McpClientInfo {
name: request.client_info.name.clone(),
});
if context.peer.peer_info().is_none() {
context.peer.set_peer_info(request);
}
Ok(self.get_info())
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let tool_name = request.name.to_string();
let start = std::time::Instant::now();
let mcp_client = context
.peer
.peer_info()
.map(|info| telemetry::McpClientInfo {
name: info.client_info.name.clone(),
});
self.sync_client(false).await;
let tcc = rmcp::handler::server::tool::ToolCallContext::new(
self,
request.clone(),
context.clone(),
);
let mut result = self.tool_router.call(tcc).await;
if is_auth_failure(&result) && self.sync_client(true).await {
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
result = self.tool_router.call(tcc).await;
}
let duration_ms = start.elapsed().as_millis() as u64;
telemetry::send_mcp_tool_with_client(
tool_name,
duration_ms,
result.is_ok(),
result
.as_ref()
.err()
.map(|e| telemetry::truncate_message(&format!("{e}"))),
mcp_client,
)
.await;
result
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
Ok(ListToolsResult {
tools: self.tool_router.list_all(),
meta: None,
next_cursor: None,
})
}
fn get_tool(&self, name: &str) -> Option<Tool> {
self.tool_router.get(name).cloned()
}
fn get_info(&self) -> ServerInfo {
railway_mcp_server_info()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tcp_proxy_tools_are_registered() {
let router = RailwayMcp::tool_router();
let tools = router.list_all();
let names = tools
.iter()
.map(|tool| tool.name.as_ref())
.collect::<Vec<_>>();
assert!(names.contains(&"list_tcp_proxies"));
assert!(names.contains(&"create_tcp_proxy"));
assert!(names.contains(&"get_tcp_proxy"));
assert!(names.contains(&"remove_tcp_proxy"));
}
#[test]
fn server_info_preserves_advertised_protocol_version() {
let info = railway_mcp_server_info();
assert_eq!(info.protocol_version, ProtocolVersion::V_2025_03_26);
assert_eq!(info.server_info.name, "railway");
assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
assert!(info.instructions.is_some());
}
fn sample_domain() -> McpDomainDetails {
McpDomainDetails {
id: "dom_123".to_string(),
domain: "api.example.com".to_string(),
kind: McpDomainKind::Custom,
target_port: Some(3000),
sync_status: "ACTIVE".to_string(),
service_domain_suffix: None,
environment_id: "env_123".to_string(),
service_id: "svc_123".to_string(),
dns_records: Vec::new(),
verification: None,
certificate: None,
}
}
fn sample_service_domain() -> McpDomainDetails {
let mut domain = sample_domain();
domain.kind = McpDomainKind::Service;
domain.domain = "api.up.railway.app".to_string();
domain.service_domain_suffix = Some("up.railway.app".to_string());
domain
}
fn sample_certificate(status: &str, retryable: Option<bool>) -> McpCertificateStatus {
McpCertificateStatus {
status: status.to_string(),
detailed_status: None,
error_message: Some("Certificate failed.".to_string()),
error_type: None,
retryable,
cdn_provider: None,
}
}
#[test]
fn mcp_domain_lookup_accepts_id_name_and_url() {
let domains = vec![sample_domain()];
assert!(find_mcp_domain(&domains, "dom_123").is_some());
assert!(find_mcp_domain(&domains, "API.EXAMPLE.COM").is_some());
assert!(find_mcp_domain(&domains, "https://api.example.com/").is_some());
assert!(find_mcp_domain(&domains, "missing.example.com").is_none());
}
#[test]
fn mcp_service_domain_input_accepts_full_domain_or_host_label() {
let domain = sample_service_domain();
assert_eq!(
mcp_service_domain_input(&domain, "web.up.railway.app").unwrap(),
"web.up.railway.app"
);
assert_eq!(
mcp_service_domain_input(&domain, "web").unwrap(),
"web.up.railway.app"
);
assert!(mcp_service_domain_input(&domain, "").is_err());
}
#[test]
fn mcp_certificate_retry_matches_dashboard_gate() {
let mut domain = sample_domain();
assert!(certificate_retry_unavailable_reason(&domain).is_some());
domain.certificate = Some(sample_certificate(CERTIFICATE_STATUS_ISSUE_FAILED, None));
assert_eq!(certificate_retry_unavailable_reason(&domain), None);
domain.certificate = Some(sample_certificate(
CERTIFICATE_STATUS_ISSUE_FAILED,
Some(true),
));
assert_eq!(certificate_retry_unavailable_reason(&domain), None);
domain.certificate = Some(sample_certificate(
CERTIFICATE_STATUS_ISSUE_FAILED,
Some(false),
));
assert!(certificate_retry_unavailable_reason(&domain).is_some());
domain.certificate = Some(sample_certificate("CERTIFICATE_STATUS_TYPE_VALID", None));
assert!(certificate_retry_unavailable_reason(&domain).is_some());
}
#[test]
fn mcp_verification_txt_value_has_one_prefix() {
assert_eq!(verification_txt_value("abc123"), "railway-verify=abc123");
assert_eq!(
verification_txt_value("railway-verify=abc123"),
"railway-verify=abc123"
);
assert_eq!(
verification_txt_value("railway-verify=railway-verify=abc123"),
"railway-verify=abc123"
);
}
}