use std::{
cmp::Ordering,
future::Future,
path::PathBuf,
str::FromStr,
sync::{Arc, RwLock},
};
use forge_core_db::models::{
project::Project,
task::{CreateTask, Task, TaskStatus, TaskWithAttemptStatus, UpdateTask},
task_attempt::TaskAttempt,
};
use forge_core_executors::{executors::BaseCodingAgent, profile::ExecutorProfileId};
use rmcp::{
ErrorData, RoleServer, ServerHandler,
handler::server::{tool::ToolRouter, wrapper::Parameters},
model::{
CallToolResult, Content, Implementation, InitializeRequestParam, ProtocolVersion,
ServerCapabilities, ServerInfo,
},
schemars,
service::RequestContext,
tool, tool_handler, tool_router,
};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json;
use tracing::info;
use uuid::Uuid;
use crate::routes::{
execution_runs::CreateExecutionRunRequest as ApiCreateExecutionRunRequest,
task_attempts::CreateTaskAttemptBody,
};
const SUPPORTED_PROTOCOL_VERSIONS: [ProtocolVersion; 2] =
[ProtocolVersion::V_2025_03_26, ProtocolVersion::V_2024_11_05];
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CreateTaskRequest {
#[schemars(description = "The ID of the project to create the task in. This is required!")]
pub project_id: Uuid,
#[schemars(description = "The title of the task")]
pub title: String,
#[schemars(description = "Optional description of the task")]
pub description: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct CreateTaskResponse {
pub task_id: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct ProjectSummary {
#[schemars(description = "The unique identifier of the project")]
pub id: String,
#[schemars(description = "The name of the project")]
pub name: String,
#[schemars(description = "The path to the git repository")]
pub git_repo_path: PathBuf,
#[schemars(description = "Optional setup script for the project")]
pub setup_script: Option<String>,
#[schemars(description = "Optional cleanup script for the project")]
pub cleanup_script: Option<String>,
#[schemars(description = "Optional development script for the project")]
pub dev_script: Option<String>,
#[schemars(description = "When the project was created")]
pub created_at: String,
#[schemars(description = "When the project was last updated")]
pub updated_at: String,
}
impl ProjectSummary {
fn from_project(project: Project) -> Self {
Self {
id: project.id.to_string(),
name: project.name,
git_repo_path: project.git_repo_path,
setup_script: project.setup_script,
cleanup_script: project.cleanup_script,
dev_script: project.dev_script,
created_at: project.created_at.to_rfc3339(),
updated_at: project.updated_at.to_rfc3339(),
}
}
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct ListProjectsResponse {
pub projects: Vec<ProjectSummary>,
pub count: usize,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ListTasksRequest {
#[schemars(description = "The ID of the project to list tasks from")]
pub project_id: Uuid,
#[schemars(
description = "Optional status filter: 'todo', 'inprogress', 'inreview', 'done', 'cancelled'"
)]
pub status: Option<String>,
#[schemars(description = "Maximum number of tasks to return (default: 50)")]
pub limit: Option<i32>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct TaskSummary {
#[schemars(description = "The unique identifier of the task")]
pub id: String,
#[schemars(description = "The title of the task")]
pub title: String,
#[schemars(description = "Current status of the task")]
pub status: String,
#[schemars(description = "When the task was created")]
pub created_at: String,
#[schemars(description = "When the task was last updated")]
pub updated_at: String,
#[schemars(description = "Whether the task has an in-progress execution attempt")]
pub has_in_progress_attempt: Option<bool>,
#[schemars(description = "Whether the task has a merged execution attempt")]
pub has_merged_attempt: Option<bool>,
#[schemars(description = "Whether the last execution attempt failed")]
pub last_attempt_failed: Option<bool>,
}
impl TaskSummary {
fn from_task_with_status(task: TaskWithAttemptStatus) -> Self {
Self {
id: task.id.to_string(),
title: task.title.to_string(),
status: task.status.to_string(),
created_at: task.created_at.to_rfc3339(),
updated_at: task.updated_at.to_rfc3339(),
has_in_progress_attempt: Some(task.has_in_progress_attempt),
has_merged_attempt: Some(task.has_merged_attempt),
last_attempt_failed: Some(task.last_attempt_failed),
}
}
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct TaskDetails {
#[schemars(description = "The unique identifier of the task")]
pub id: String,
#[schemars(description = "The title of the task")]
pub title: String,
#[schemars(description = "Optional description of the task")]
pub description: Option<String>,
#[schemars(description = "Current status of the task")]
pub status: String,
#[schemars(description = "When the task was created")]
pub created_at: String,
#[schemars(description = "When the task was last updated")]
pub updated_at: String,
#[schemars(description = "Whether the task has an in-progress execution attempt")]
pub has_in_progress_attempt: Option<bool>,
#[schemars(description = "Whether the task has a merged execution attempt")]
pub has_merged_attempt: Option<bool>,
#[schemars(description = "Whether the last execution attempt failed")]
pub last_attempt_failed: Option<bool>,
}
impl TaskDetails {
fn from_task(task: Task) -> Self {
Self {
id: task.id.to_string(),
title: task.title,
description: task.description,
status: task.status.to_string(),
created_at: task.created_at.to_rfc3339(),
updated_at: task.updated_at.to_rfc3339(),
has_in_progress_attempt: None,
has_merged_attempt: None,
last_attempt_failed: None,
}
}
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct ListTasksResponse {
pub tasks: Vec<TaskSummary>,
pub count: usize,
pub project_id: String,
pub applied_filters: ListTasksFilters,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct ListTasksFilters {
pub status: Option<String>,
pub limit: i32,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct UpdateTaskRequest {
#[schemars(description = "The ID of the task to update")]
pub task_id: Uuid,
#[schemars(description = "New title for the task")]
pub title: Option<String>,
#[schemars(description = "New description for the task")]
pub description: Option<String>,
#[schemars(description = "New status: 'todo', 'inprogress', 'inreview', 'done', 'cancelled'")]
pub status: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct UpdateTaskResponse {
pub task: TaskDetails,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DeleteTaskRequest {
#[schemars(description = "The ID of the task to delete")]
pub task_id: Uuid,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct StartTaskAttemptRequest {
#[schemars(description = "The ID of the task to start")]
pub task_id: Uuid,
#[schemars(
description = "The coding agent executor to run ('CLAUDE_CODE', 'CODEX', 'GEMINI', 'CURSOR_AGENT', 'OPENCODE')"
)]
pub executor: String,
#[schemars(description = "Optional executor variant, if needed")]
pub variant: Option<String>,
#[schemars(description = "The base branch to use for the attempt")]
pub base_branch: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct StartTaskAttemptResponse {
pub task_id: String,
pub attempt_id: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct DeleteTaskResponse {
pub deleted_task_id: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GetTaskRequest {
#[schemars(description = "The ID of the task to retrieve")]
pub task_id: Uuid,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct GetTaskResponse {
pub task: TaskDetails,
}
// ============================================================================
// ExecutionRun MCP Types
// ============================================================================
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct StartExecutionRunRequest {
#[schemars(description = "The ID of the project to run in")]
pub project_id: Uuid,
#[schemars(description = "The prompt/instruction for the executor")]
pub prompt: String,
#[schemars(
description = "The coding agent executor to use ('CLAUDE_CODE', 'CODEX', 'GEMINI', 'CURSOR_AGENT', 'OPENCODE')"
)]
pub executor: String,
#[schemars(description = "Optional executor variant")]
pub variant: Option<String>,
#[schemars(description = "The base branch to use (defaults to 'main')")]
pub base_branch: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct StartExecutionRunResponse {
pub execution_run_id: String,
pub project_id: String,
pub branch: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ListExecutionRunsRequest {
#[schemars(description = "Optional project ID filter")]
pub project_id: Option<Uuid>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct ExecutionRunSummary {
pub id: String,
pub project_id: String,
pub branch: String,
pub target_branch: String,
pub executor: String,
pub created_at: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct ListExecutionRunsResponse {
pub runs: Vec<ExecutionRunSummary>,
pub count: usize,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GetExecutionRunRequest {
#[schemars(description = "The ID of the execution run")]
pub execution_run_id: Uuid,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct GetExecutionRunResponse {
pub id: String,
pub project_id: String,
pub branch: String,
pub target_branch: String,
pub executor: String,
pub prompt: String,
pub created_at: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct StopExecutionRunRequest {
#[schemars(description = "The ID of the execution run to stop")]
pub execution_run_id: Uuid,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct StopExecutionRunResponse {
pub stopped: bool,
pub execution_run_id: String,
}
#[derive(Debug, Clone)]
pub struct TaskServer {
client: reqwest::Client,
base_url: String,
tool_router: ToolRouter<TaskServer>,
negotiated_protocol_version: Arc<RwLock<ProtocolVersion>>,
}
impl TaskServer {
pub fn new(base_url: &str) -> Self {
Self {
client: reqwest::Client::new(),
base_url: base_url.to_string(),
tool_router: Self::tool_router(),
negotiated_protocol_version: Arc::new(RwLock::new(Self::latest_supported_protocol())),
}
}
}
#[derive(Debug, Deserialize)]
struct ApiResponseEnvelope<T> {
success: bool,
data: Option<T>,
message: Option<String>,
}
impl TaskServer {
fn success<T: Serialize>(data: &T) -> Result<CallToolResult, ErrorData> {
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(data)
.unwrap_or_else(|_| "Failed to serialize response".to_string()),
)]))
}
fn err_value(v: serde_json::Value) -> Result<CallToolResult, ErrorData> {
Ok(CallToolResult::error(vec![Content::text(
serde_json::to_string_pretty(&v)
.unwrap_or_else(|_| "Failed to serialize error".to_string()),
)]))
}
fn err<S: Into<String>>(msg: S, details: Option<S>) -> Result<CallToolResult, ErrorData> {
let mut v = serde_json::json!({"success": false, "error": msg.into()});
if let Some(d) = details {
v["details"] = serde_json::json!(d.into());
};
Self::err_value(v)
}
async fn send_json<T: DeserializeOwned>(
&self,
rb: reqwest::RequestBuilder,
) -> Result<T, CallToolResult> {
let resp = rb
.send()
.await
.map_err(|e| Self::err("Failed to connect to AF API", Some(&e.to_string())).unwrap())?;
if !resp.status().is_success() {
let status = resp.status();
return Err(
Self::err(format!("AF API returned error status: {}", status), None).unwrap(),
);
}
let api_response = resp.json::<ApiResponseEnvelope<T>>().await.map_err(|e| {
Self::err("Failed to parse AF API response", Some(&e.to_string())).unwrap()
})?;
if !api_response.success {
let msg = api_response.message.as_deref().unwrap_or("Unknown error");
return Err(Self::err("AF API returned error", Some(msg)).unwrap());
}
api_response
.data
.ok_or_else(|| Self::err("AF API response missing data field", None).unwrap())
}
fn url(&self, path: &str) -> String {
format!(
"{}/{}",
self.base_url.trim_end_matches('/'),
path.trim_start_matches('/')
)
}
fn supported_protocol_versions() -> &'static [ProtocolVersion] {
&SUPPORTED_PROTOCOL_VERSIONS
}
fn latest_supported_protocol() -> ProtocolVersion {
Self::supported_protocol_versions()
.first()
.expect("supported protocols list cannot be empty")
.clone()
}
fn minimum_supported_protocol() -> ProtocolVersion {
Self::supported_protocol_versions()
.last()
.expect("supported protocols list cannot be empty")
.clone()
}
fn current_protocol_version(&self) -> ProtocolVersion {
self.negotiated_protocol_version
.read()
.expect("protocol negotiation lock poisoned")
.clone()
}
fn set_negotiated_protocol_version(&self, version: ProtocolVersion) {
let mut guard = self
.negotiated_protocol_version
.write()
.expect("protocol negotiation lock poisoned");
*guard = version;
}
fn server_info_for_version(&self, protocol_version: ProtocolVersion) -> ServerInfo {
ServerInfo {
protocol_version,
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation {
name: "automagik-forge".to_string(),
version: "1.0.0".to_string(),
title: None,
icons: None,
website_url: None,
},
instructions: Some("A task and project management server. If you need to create or update tickets or tasks then use these tools. Most of them absolutely require that you pass the `project_id` of the project that you are currently working on. This should be provided to you. Call `list_tasks` to fetch the `task_ids` of all the tasks in a project`. TOOLS: 'list_projects', 'list_tasks', 'create_task', 'start_task_attempt', 'get_task', 'update_task', 'delete_task'. Make sure to pass `project_id` or `task_id` where required. You can use list tools to get the available ids.".to_string()),
}
}
fn log_downgrade_if_needed(requested: &ProtocolVersion, negotiated: &ProtocolVersion) {
let latest = Self::latest_supported_protocol();
if negotiated != &latest {
info!(
requested_protocol = %requested,
negotiated_protocol = %negotiated,
latest_supported_protocol = %latest,
"Downgrading MCP protocol version for backward compatibility"
);
}
}
fn negotiate_protocol_version(
requested: &ProtocolVersion,
) -> Result<ProtocolVersion, ErrorData> {
for supported in Self::supported_protocol_versions() {
match requested.partial_cmp(supported) {
Some(Ordering::Greater) | Some(Ordering::Equal) => {
return Ok(supported.clone());
}
Some(Ordering::Less) => continue,
None => {
return Err(ErrorData::invalid_params(
format!(
"Unable to compare requested MCP protocol version ({requested}) with supported versions"
),
Some(serde_json::json!({
"requested_protocol": requested.to_string(),
"supported_protocols": Self::supported_protocol_versions()
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>(),
})),
));
}
}
}
Err(Self::protocol_version_too_old_error(requested))
}
fn protocol_version_too_old_error(requested: &ProtocolVersion) -> ErrorData {
let minimum = Self::minimum_supported_protocol();
ErrorData::invalid_params(
format!(
"Requested MCP protocol version ({requested}) is older than the supported minimum ({minimum})"
),
Some(serde_json::json!({
"requested_protocol": requested.to_string(),
"minimum_supported_protocol": minimum.to_string(),
"supported_protocols": Self::supported_protocol_versions()
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>(),
})),
)
}
}
#[tool_router]
impl TaskServer {
#[tool(
description = "Create a new task/ticket in a project. Always pass the `project_id` of the project you want to create the task in - it is required!"
)]
async fn create_task(
&self,
Parameters(CreateTaskRequest {
project_id,
title,
description,
}): Parameters<CreateTaskRequest>,
) -> Result<CallToolResult, ErrorData> {
let url = self.url("/api/tasks");
let task: Task = match self
.send_json(
self.client
.post(&url)
.json(&CreateTask::from_title_description(
project_id,
title,
description,
)),
)
.await
{
Ok(t) => t,
Err(e) => return Ok(e),
};
TaskServer::success(&CreateTaskResponse {
task_id: task.id.to_string(),
})
}
#[tool(description = "List all the available projects")]
async fn list_projects(&self) -> Result<CallToolResult, ErrorData> {
let url = self.url("/api/projects");
let projects: Vec<Project> = match self.send_json(self.client.get(&url)).await {
Ok(ps) => ps,
Err(e) => return Ok(e),
};
let project_summaries: Vec<ProjectSummary> = projects
.into_iter()
.map(ProjectSummary::from_project)
.collect();
let response = ListProjectsResponse {
count: project_summaries.len(),
projects: project_summaries,
};
TaskServer::success(&response)
}
#[tool(
description = "List all the task/tickets in a project with optional filtering and execution status. `project_id` is required!"
)]
async fn list_tasks(
&self,
Parameters(ListTasksRequest {
project_id,
status,
limit,
}): Parameters<ListTasksRequest>,
) -> Result<CallToolResult, ErrorData> {
let status_filter = if let Some(ref status_str) = status {
match TaskStatus::from_str(status_str) {
Ok(s) => Some(s),
Err(_) => {
return Self::err(
"Invalid status filter. Valid values: 'todo', 'in-progress', 'in-review', 'done', 'cancelled'".to_string(),
Some(status_str.to_string()),
);
}
}
} else {
None
};
let url = self.url(&format!("/api/tasks?project_id={}", project_id));
let all_tasks: Vec<TaskWithAttemptStatus> =
match self.send_json(self.client.get(&url)).await {
Ok(t) => t,
Err(e) => return Ok(e),
};
let task_limit = limit.unwrap_or(50).max(0) as usize;
let filtered = all_tasks.into_iter().filter(|t| {
if let Some(ref want) = status_filter {
&t.status == want
} else {
true
}
});
let limited: Vec<TaskWithAttemptStatus> = filtered.take(task_limit).collect();
let task_summaries: Vec<TaskSummary> = limited
.into_iter()
.map(TaskSummary::from_task_with_status)
.collect();
let response = ListTasksResponse {
count: task_summaries.len(),
tasks: task_summaries,
project_id: project_id.to_string(),
applied_filters: ListTasksFilters {
status: status.clone(),
limit: task_limit as i32,
},
};
TaskServer::success(&response)
}
#[tool(description = "Start working on a task by creating and launching a new task attempt.")]
async fn start_task_attempt(
&self,
Parameters(StartTaskAttemptRequest {
task_id,
executor,
variant,
base_branch,
}): Parameters<StartTaskAttemptRequest>,
) -> Result<CallToolResult, ErrorData> {
let base_branch = base_branch.trim().to_string();
if base_branch.is_empty() {
return Self::err("Base branch must not be empty.".to_string(), None::<String>);
}
let executor_trimmed = executor.trim();
if executor_trimmed.is_empty() {
return Self::err("Executor must not be empty.".to_string(), None::<String>);
}
let normalized_executor = executor_trimmed.replace('-', "_").to_ascii_uppercase();
let base_executor = match BaseCodingAgent::from_str(&normalized_executor) {
Ok(exec) => exec,
Err(_) => {
return Self::err(
format!("Unknown executor '{executor_trimmed}'."),
None::<String>,
);
}
};
let variant = variant.and_then(|v| {
let trimmed = v.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
let executor_profile_id = ExecutorProfileId {
executor: base_executor,
variant,
};
let payload = CreateTaskAttemptBody {
task_id,
executor_profile_id,
base_branch,
use_worktree: None, // Default to worktree execution
};
let url = self.url("/api/task-attempts");
let attempt: TaskAttempt = match self.send_json(self.client.post(&url).json(&payload)).await
{
Ok(attempt) => attempt,
Err(e) => return Ok(e),
};
let response = StartTaskAttemptResponse {
task_id: attempt.task_id.to_string(),
attempt_id: attempt.id.to_string(),
};
TaskServer::success(&response)
}
#[tool(
description = "Update an existing task/ticket's title, description, or status. `project_id` and `task_id` are required! `title`, `description`, and `status` are optional."
)]
async fn update_task(
&self,
Parameters(UpdateTaskRequest {
task_id,
title,
description,
status,
}): Parameters<UpdateTaskRequest>,
) -> Result<CallToolResult, ErrorData> {
let status = if let Some(ref status_str) = status {
match TaskStatus::from_str(status_str) {
Ok(s) => Some(s),
Err(_) => {
return Self::err(
"Invalid status filter. Valid values: 'todo', 'in-progress', 'in-review', 'done', 'cancelled'".to_string(),
Some(status_str.to_string()),
);
}
}
} else {
None
};
let payload = UpdateTask {
title,
description,
status,
parent_task_attempt: None,
image_ids: None,
};
let url = self.url(&format!("/api/tasks/{}", task_id));
let updated_task: Task = match self.send_json(self.client.put(&url).json(&payload)).await {
Ok(t) => t,
Err(e) => return Ok(e),
};
let details = TaskDetails::from_task(updated_task);
let repsonse = UpdateTaskResponse { task: details };
TaskServer::success(&repsonse)
}
#[tool(
description = "Delete a task/ticket from a project. `project_id` and `task_id` are required!"
)]
async fn delete_task(
&self,
Parameters(DeleteTaskRequest { task_id }): Parameters<DeleteTaskRequest>,
) -> Result<CallToolResult, ErrorData> {
let url = self.url(&format!("/api/tasks/{}", task_id));
if let Err(e) = self
.send_json::<serde_json::Value>(self.client.delete(&url))
.await
{
return Ok(e);
}
let repsonse = DeleteTaskResponse {
deleted_task_id: Some(task_id.to_string()),
};
TaskServer::success(&repsonse)
}
#[tool(
description = "Get detailed information (like task description) about a specific task/ticket. You can use `list_tasks` to find the `task_ids` of all tasks in a project. `project_id` and `task_id` are required!"
)]
async fn get_task(
&self,
Parameters(GetTaskRequest { task_id }): Parameters<GetTaskRequest>,
) -> Result<CallToolResult, ErrorData> {
let url = self.url(&format!("/api/tasks/{}", task_id));
let task: Task = match self.send_json(self.client.get(&url)).await {
Ok(t) => t,
Err(e) => return Ok(e),
};
let details = TaskDetails::from_task(task);
let response = GetTaskResponse { task: details };
TaskServer::success(&response)
}
// =========================================================================
// ExecutionRun Tools - Lightweight executor invocation without Task overhead
// =========================================================================
#[tool(
description = "Start a lightweight execution run. Unlike task attempts, runs don't require creating a task first. Ideal for serverless micro-tasks like generating commit messages or PR descriptions."
)]
async fn start_execution_run(
&self,
Parameters(StartExecutionRunRequest {
project_id,
prompt,
executor,
variant,
base_branch,
}): Parameters<StartExecutionRunRequest>,
) -> Result<CallToolResult, ErrorData> {
let executor_trimmed = executor.trim();
if executor_trimmed.is_empty() {
return Self::err("Executor must not be empty.".to_string(), None::<String>);
}
let normalized_executor = executor_trimmed.replace('-', "_").to_ascii_uppercase();
let base_executor = match BaseCodingAgent::from_str(&normalized_executor) {
Ok(exec) => exec,
Err(_) => {
return Self::err(
format!("Unknown executor '{executor_trimmed}'."),
None::<String>,
);
}
};
let variant = variant.and_then(|v| {
let trimmed = v.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
let executor_profile_id = ExecutorProfileId {
executor: base_executor,
variant,
};
let payload = ApiCreateExecutionRunRequest {
project_id,
prompt,
executor_profile_id,
base_branch,
};
let url = self.url("/api/execution-runs");
// Response contains { execution_run, execution_process }
#[derive(Deserialize)]
struct ApiExecutionRunResponse {
execution_run: forge_core_db::models::execution_run::ExecutionRun,
}
let resp: ApiExecutionRunResponse =
match self.send_json(self.client.post(&url).json(&payload)).await {
Ok(r) => r,
Err(e) => return Ok(e),
};
let response = StartExecutionRunResponse {
execution_run_id: resp.execution_run.id.to_string(),
project_id: resp.execution_run.project_id.to_string(),
branch: resp.execution_run.branch,
};
TaskServer::success(&response)
}
#[tool(description = "List execution runs, optionally filtered by project_id")]
async fn list_execution_runs(
&self,
Parameters(ListExecutionRunsRequest { project_id }): Parameters<ListExecutionRunsRequest>,
) -> Result<CallToolResult, ErrorData> {
let url = match project_id {
Some(pid) => self.url(&format!("/api/execution-runs?project_id={}", pid)),
None => self.url("/api/execution-runs"),
};
let runs: Vec<forge_core_db::models::execution_run::ExecutionRun> =
match self.send_json(self.client.get(&url)).await {
Ok(r) => r,
Err(e) => return Ok(e),
};
let summaries: Vec<ExecutionRunSummary> = runs
.into_iter()
.map(|r| ExecutionRunSummary {
id: r.id.to_string(),
project_id: r.project_id.to_string(),
branch: r.branch,
target_branch: r.target_branch,
executor: r.executor,
created_at: r.created_at.to_rfc3339(),
})
.collect();
let response = ListExecutionRunsResponse {
count: summaries.len(),
runs: summaries,
};
TaskServer::success(&response)
}
#[tool(description = "Get details of a specific execution run")]
async fn get_execution_run(
&self,
Parameters(GetExecutionRunRequest { execution_run_id }): Parameters<GetExecutionRunRequest>,
) -> Result<CallToolResult, ErrorData> {
let url = self.url(&format!("/api/execution-runs/{}", execution_run_id));
let run: forge_core_db::models::execution_run::ExecutionRun =
match self.send_json(self.client.get(&url)).await {
Ok(r) => r,
Err(e) => return Ok(e),
};
let response = GetExecutionRunResponse {
id: run.id.to_string(),
project_id: run.project_id.to_string(),
branch: run.branch,
target_branch: run.target_branch,
executor: run.executor,
prompt: run.prompt,
created_at: run.created_at.to_rfc3339(),
};
TaskServer::success(&response)
}
#[tool(description = "Stop a running execution run")]
async fn stop_execution_run(
&self,
Parameters(StopExecutionRunRequest { execution_run_id }): Parameters<
StopExecutionRunRequest,
>,
) -> Result<CallToolResult, ErrorData> {
let url = self.url(&format!("/api/execution-runs/{}/stop", execution_run_id));
if let Err(e) = self
.send_json::<serde_json::Value>(self.client.post(&url))
.await
{
return Ok(e);
}
let response = StopExecutionRunResponse {
stopped: true,
execution_run_id: execution_run_id.to_string(),
};
TaskServer::success(&response)
}
}
#[tool_handler]
impl ServerHandler for TaskServer {
#[allow(clippy::manual_async_fn)]
fn initialize(
&self,
request: InitializeRequestParam,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<ServerInfo, ErrorData>> + Send + '_ {
async move {
if context.peer.peer_info().is_none() {
context.peer.set_peer_info(request.clone());
}
let requested_version = request.protocol_version.clone();
let negotiated_version = match Self::negotiate_protocol_version(&requested_version) {
Ok(version) => version,
Err(error) => return Err(error),
};
Self::log_downgrade_if_needed(&requested_version, &negotiated_version);
self.set_negotiated_protocol_version(negotiated_version.clone());
Ok(self.server_info_for_version(negotiated_version))
}
}
/// Returns server info that reflects the currently negotiated protocol version so
/// any follow-up responses stay aligned with the handshake.
fn get_info(&self) -> ServerInfo {
let protocol_version = self.current_protocol_version();
self.server_info_for_version(protocol_version)
}
}
#[cfg(test)]
mod tests {
use rmcp::model::ErrorCode;
use super::*;
fn custom_protocol_version(version: &str) -> ProtocolVersion {
serde_json::from_str::<ProtocolVersion>(&format!("\"{version}\"")).unwrap()
}
#[test]
fn client_requesting_latest_version_receives_latest() {
let negotiated =
TaskServer::negotiate_protocol_version(&ProtocolVersion::V_2025_03_26).unwrap();
assert_eq!(negotiated, ProtocolVersion::V_2025_03_26);
}
#[test]
fn client_requesting_older_version_negotiates_down() {
let negotiated =
TaskServer::negotiate_protocol_version(&ProtocolVersion::V_2024_11_05).unwrap();
assert_eq!(negotiated, ProtocolVersion::V_2024_11_05);
}
#[test]
fn client_requesting_newer_version_falls_back_to_latest() {
let version = custom_protocol_version("2026-01-01");
let negotiated = TaskServer::negotiate_protocol_version(&version).unwrap();
assert_eq!(negotiated, ProtocolVersion::V_2025_03_26);
}
#[test]
fn client_requesting_too_old_version_receives_error() {
let version = custom_protocol_version("2023-01-01");
let error = TaskServer::negotiate_protocol_version(&version).unwrap_err();
assert_eq!(error.code, ErrorCode::INVALID_PARAMS);
}
#[test]
fn get_info_reflects_negotiated_version() {
let server = TaskServer::new("http://example.com");
server.set_negotiated_protocol_version(ProtocolVersion::V_2024_11_05);
let info = server.get_info();
assert_eq!(info.protocol_version, ProtocolVersion::V_2024_11_05);
}
}