use rmcp::{ErrorData as McpError, model::*};
use crate::{
client::post_graphql,
controllers::{
github::{resolve_repo_branch, validate_repo_name},
project::{get_environment_instances, service_instances_in_env},
regions::{fetch_regions_for_project, resolve_deploy_region_id},
},
gql::mutations,
workspace::workspaces,
};
use super::super::handler::RailwayMcp;
use super::super::params::{
ConnectServiceSourceParams, CreateEnvironmentParams, CreateProjectParams, CreateServiceParams,
EnvironmentStatusParams, RemoveServiceParams, ServiceParams, UpdateServiceParams,
};
impl RailwayMcp {
pub(crate) async fn do_create_project(
&self,
params: CreateProjectParams,
) -> Result<CallToolResult, McpError> {
let workspace_id = match params.workspace_id {
Some(id) => Some(id),
None => {
let all_workspaces = workspaces().await.map_err(|e| {
McpError::internal_error(format!("Failed to fetch workspaces: {e}"), None)
})?;
let team_workspaces: Vec<_> = all_workspaces
.iter()
.filter(|w| w.team_id().is_some())
.collect();
match team_workspaces.len() {
0 => None,
1 => Some(team_workspaces[0].id().to_string()),
_ => {
let list = team_workspaces
.iter()
.map(|w| format!("- {} (id: {})", w.name(), w.id()))
.collect::<Vec<_>>()
.join("\n");
return Err(McpError::invalid_params(
format!(
"Multiple team workspaces found. Please specify a workspace_id:\n{list}"
),
None,
));
}
}
}
};
let vars = mutations::project_create::Variables {
name: Some(params.name),
description: params.description,
workspace_id,
};
let result = post_graphql::<mutations::ProjectCreate, _>(
&self.client,
self.configs.get_backboard(),
vars,
)
.await
.map_err(|e| McpError::internal_error(format!("Failed to create project: {e}"), None))?;
let project = &result.project_create;
let env_info: Vec<String> = project
.environments
.edges
.iter()
.map(|e| format!("{} (id: {})", e.node.name, e.node.id))
.collect();
Ok(CallToolResult::success(vec![Content::text(format!(
"Project created: {} (id: {})\nEnvironments: {}",
project.name,
project.id,
if env_info.is_empty() {
"none".to_string()
} else {
env_info.join(", ")
}
))]))
}
pub(crate) async fn do_create_environment(
&self,
params: CreateEnvironmentParams,
) -> Result<CallToolResult, McpError> {
let linked = self.configs.get_linked_project().await.ok();
let project_id = params
.project_id
.or_else(|| linked.map(|l| l.project))
.ok_or_else(|| {
McpError::invalid_params(
"No project_id provided and no linked project. Run 'railway link' or pass a project_id.",
None,
)
})?;
let vars = mutations::environment_create::Variables {
project_id,
name: params.name,
source_id: params.source_environment_id,
apply_changes_in_background: None,
};
let result = post_graphql::<mutations::EnvironmentCreate, _>(
&self.client,
self.configs.get_backboard(),
vars,
)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to create environment: {e}"), None)
})?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Environment created: {} (id: {})",
result.environment_create.name, result.environment_create.id
))]))
}
pub(crate) async fn do_create_service(
&self,
params: CreateServiceParams,
) -> Result<CallToolResult, McpError> {
if params.source_image.is_some() && params.source_repo.is_some() {
return Err(McpError::invalid_params(
"Cannot specify both source_repo and source_image. Provide one or the other.",
None,
));
}
if params.branch.is_some() && params.source_repo.is_none() {
return Err(McpError::invalid_params(
"branch can only be used with source_repo.",
None,
));
}
let ctx = self
.resolve_context(params.project_id, params.environment_id)
.await?;
let branch = if let Some(repo) = params.source_repo.as_deref() {
validate_repo_name(repo).map_err(|e| McpError::invalid_params(e.to_string(), None))?;
Some(
resolve_repo_branch(&self.client, &self.configs, repo, params.branch)
.await
.map_err(|e| {
McpError::invalid_params(
format!("Failed to resolve repo branch: {e}"),
None,
)
})?,
)
} else {
None
};
let source = if params.source_image.is_some() || params.source_repo.is_some() {
Some(mutations::service_create::ServiceSourceInput {
image: params.source_image,
repo: params.source_repo,
})
} else {
None
};
let vars = mutations::service_create::Variables {
name: params.name,
project_id: ctx.project_id,
environment_id: ctx.environment_id,
source,
branch,
variables: None,
};
let result = post_graphql::<mutations::ServiceCreate, _>(
&self.client,
self.configs.get_backboard(),
vars,
)
.await
.map_err(|e| McpError::internal_error(format!("Failed to create service: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Service created: {} (id: {})",
result.service_create.name, result.service_create.id
))]))
}
pub(crate) async fn do_connect_service_source(
&self,
params: ConnectServiceSourceParams,
) -> Result<CallToolResult, McpError> {
if params.source_image.is_some() == params.source_repo.is_some() {
return Err(McpError::invalid_params(
"Provide exactly one of source_repo or source_image.",
None,
));
}
if params.branch.is_some() && params.source_repo.is_none() {
return Err(McpError::invalid_params(
"branch can only be used with source_repo.",
None,
));
}
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let (repo, branch, image) = if let Some(repo) = params.source_repo {
validate_repo_name(&repo).map_err(|e| McpError::invalid_params(e.to_string(), None))?;
let branch = resolve_repo_branch(&self.client, &self.configs, &repo, params.branch)
.await
.map_err(|e| {
McpError::invalid_params(format!("Failed to resolve repo branch: {e}"), None)
})?;
(Some(repo), Some(branch), None)
} else {
(None, None, params.source_image)
};
let result = post_graphql::<mutations::ServiceConnect, _>(
&self.client,
self.configs.get_backboard(),
mutations::service_connect::Variables {
id: ctx.service_id.clone(),
input: mutations::service_connect::ServiceConnectInput {
repo: repo.clone(),
branch: branch.clone(),
image: image.clone(),
},
},
)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to connect service source: {e}"), None)
})?;
let source = match (&repo, &branch, &image) {
(Some(repo), Some(branch), _) => format!("{repo}@{branch}"),
(_, _, Some(image)) => image.clone(),
_ => "source".to_string(),
};
Ok(CallToolResult::success(vec![Content::text(format!(
"Connected service source: {} (id: {}) -> {}",
result.service_connect.name, result.service_connect.id, source
))]))
}
pub(crate) async fn do_disconnect_service_source(
&self,
params: ServiceParams,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let result = post_graphql::<mutations::ServiceDisconnect, _>(
&self.client,
self.configs.get_backboard(),
mutations::service_disconnect::Variables {
id: ctx.service_id.clone(),
},
)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to disconnect service source: {e}"), None)
})?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Disconnected service source: {} (id: {})",
result.service_disconnect.name, result.service_disconnect.id
))]))
}
pub(crate) async fn do_remove_service(
&self,
params: RemoveServiceParams,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let vars = mutations::service_delete::Variables {
environment_id: ctx.environment_id,
service_id: ctx.service_id,
};
post_graphql::<mutations::ServiceDelete, _>(
&self.client,
self.configs.get_backboard(),
vars,
)
.await
.map_err(|e| McpError::internal_error(format!("Failed to remove service: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(
"Service removed successfully.".to_string(),
)]))
}
pub(crate) async fn do_update_service(
&self,
params: UpdateServiceParams,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_service_context(params.project_id, params.service_id, params.environment_id)
.await?;
let restart_policy_type =
params
.restart_policy_type
.map(|s| match s.to_uppercase().as_str() {
"ALWAYS" => mutations::service_instance_update::RestartPolicyType::ALWAYS,
"NEVER" => mutations::service_instance_update::RestartPolicyType::NEVER,
"ON_FAILURE" => {
mutations::service_instance_update::RestartPolicyType::ON_FAILURE
}
other => mutations::service_instance_update::RestartPolicyType::Other(
other.to_string(),
),
});
let region = match params.region {
Some(region_input) => {
let regions =
fetch_regions_for_project(&self.client, &self.configs, Some(&ctx.project_id))
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to fetch regions: {e}"), None)
})?;
Some(
resolve_deploy_region_id(®ions, ®ion_input)
.map_err(|e| McpError::invalid_params(e.to_string(), None))?,
)
}
None => None,
};
let input = mutations::service_instance_update::ServiceInstanceUpdateInput {
build_command: params.build_command,
start_command: params.start_command,
num_replicas: params.num_replicas,
healthcheck_path: params.health_check_path,
healthcheck_timeout: params.healthcheck_timeout,
sleep_application: params.sleep_application,
root_directory: params.root_directory,
cron_schedule: params.cron_schedule,
dockerfile_path: params.dockerfile_path,
restart_policy_type,
restart_policy_max_retries: params.restart_policy_max_retries,
pre_deploy_command: params.pre_deploy_command,
region,
railway_config_file: params.railway_config_file,
watch_patterns: params.watch_patterns,
..Default::default()
};
let vars = mutations::service_instance_update::Variables {
service_id: ctx.service_id,
environment_id: Some(ctx.environment_id),
input,
};
let result = post_graphql::<mutations::ServiceInstanceUpdate, _>(
&self.client,
self.configs.get_backboard(),
vars,
)
.await
.map_err(|e| McpError::internal_error(format!("Failed to update service: {e}"), None))?;
if result.service_instance_update {
Ok(CallToolResult::success(vec![Content::text(
"Service updated successfully.".to_string(),
)]))
} else {
Ok(CallToolResult::success(vec![Content::text(
"Service settings are already up to date (no changes applied).".to_string(),
)]))
}
}
pub(crate) async fn do_environment_status(
&self,
params: EnvironmentStatusParams,
) -> Result<CallToolResult, McpError> {
let ctx = self
.resolve_context(params.project_id, params.environment_id)
.await?;
let env = ctx
.project
.environments
.edges
.iter()
.find(|e| e.node.id == ctx.environment_id)
.ok_or_else(|| {
McpError::internal_error("Environment not found in project data.".to_string(), None)
})?;
let mut output = format!("## Environment: {} ({})\n\n", env.node.name, env.node.id);
output.push_str("Service | Status | Active Deployments | Latest Deploy\n");
output.push_str("--------|--------|-------------------|---------------\n");
let environment_instances =
get_environment_instances(&self.client, &self.configs, &ctx.project_id, &env.node.id)
.await
.map_err(|e| {
McpError::internal_error(
format!("Failed to get environment instances: {e}"),
None,
)
})?;
let service_instances = service_instances_in_env(&environment_instances);
for si_edge in service_instances {
let node = &si_edge.node;
let status = node
.latest_deployment
.as_ref()
.map(|d| format!("{:?}", d.status))
.unwrap_or_else(|| "No deployment".to_string());
let active = node.active_deployments.len();
let deploy_time = node
.latest_deployment
.as_ref()
.map(|d| d.created_at.to_string())
.unwrap_or_else(|| "-".to_string());
output.push_str(&format!(
"{} | {} | {} | {}\n",
node.service_name, status, active, deploy_time
));
}
if service_instances.is_empty() {
output.push_str("No services in this environment.\n");
}
Ok(CallToolResult::success(vec![Content::text(output)]))
}
}