use rmcp::handler::server::wrapper::Parameters;
use rmcp::{tool, tool_router};
use crate::commands;
use super::params::*;
use super::server::{SaraServer, mcp_err, ok_json};
#[tool_router(router = lifecycle_router, vis = "pub(crate)")]
impl SaraServer {
#[tool(
description = "Mark a task complete: stops its timer, renumbers display ids (keep using the UUID), and spawns the next occurrence of a recurring task. Errors if the task is blocked by unfinished dependencies, or if it is targeted by display id from a different git branch than it is tied to; force=true overrides both. Call it only once the task's PR has merged, not when the PR is opened.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = false
)
)]
fn done(&self, Parameters(p): Parameters<DoneParams>) -> Result<String, String> {
let v = self
.with_project(p.project_path.as_deref(), "mcp done", |conn, cfg| {
commands::done::done_value(conn, cfg, &p.id, p.force.unwrap_or(false))
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Attach a URL, such as a PR or issue, to a task. `label` is optional display text.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = false
)
)]
fn link(&self, Parameters(p): Parameters<LinkParams>) -> Result<String, String> {
let v = self
.with_project(p.project_path.as_deref(), "mcp link", |conn, _cfg| {
commands::annotate::link_value(conn, &p.id, &p.url, p.label.as_deref())
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Manage task dependencies. action=\"on\": `id` becomes blocked by `other`; \"off\": remove that edge; \"list\": show the task's blockers and what it blocks. `other` is required for on/off; edges that would form a cycle are refused.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
fn dep(&self, Parameters(p): Parameters<DepParams>) -> Result<String, String> {
let v = self
.with_project(p.project_path.as_deref(), "mcp dep", |conn, cfg| {
match p.action.as_str() {
"on" => {
let other = p
.other
.as_deref()
.ok_or_else(|| anyhow::anyhow!("dep action \"on\" requires `other`"))?;
commands::dep::dep_on_value(conn, cfg, &p.id, other)
}
"off" => {
let other = p.other.as_deref().ok_or_else(|| {
anyhow::anyhow!("dep action \"off\" requires `other`")
})?;
commands::dep::dep_off_value(conn, cfg, &p.id, other)
}
"list" => commands::dep::dep_list_value(conn, &p.id),
other => {
anyhow::bail!(
"unknown dep action `{other}` (expected \"on\", \"off\", or \"list\")"
)
}
}
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Prove a task green then stamp its guide as validated against the project's current git HEAD. Fail-closed: runs each acceptance criterion's verify command and refuses (errors) unless all are present and pass. Criteria already proven at the current commit with a clean working tree are reused from cache (skipped); identical verify commands run once. Errors if the project is not a git repo.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = true
)
)]
fn validate(&self, Parameters(p): Parameters<IdParams>) -> Result<String, String> {
let v = self
.with_project(p.project_path.as_deref(), "mcp validate", |conn, _cfg| {
commands::guide::validate_value(
conn,
&p.id,
false,
commands::guide::GateOutput::Capture,
false,
)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Set task fields without opening the TUI; pass at least one. `tags` REPLACES the whole tag set. Clear fields with `clear_due`, `clear_tags`, `clear_estimate` or `clear_recur`.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
fn modify(&self, Parameters(p): Parameters<ModifyParams>) -> Result<String, String> {
let tags = p.tags.clone().unwrap_or_default();
let v = self
.with_project(p.project_path.as_deref(), "mcp modify", |conn, cfg| {
commands::modify::modify_value(
conn,
cfg,
&p.id,
p.description.as_deref(),
p.priority.as_deref(),
p.due.as_deref(),
p.clear_due.unwrap_or(false),
&tags,
p.clear_tags.unwrap_or(false),
p.estimate.as_deref(),
p.clear_estimate.unwrap_or(false),
p.every.as_deref(),
p.clear_recur.unwrap_or(false),
)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Resolve a feedback item by its `feedback_id` (the annotation id from the `feedback`/`info` output — NOT a task id). Optionally cite the `run_id` (from `record_run`) that addressed it.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
fn resolve(&self, Parameters(p): Parameters<ResolveParams>) -> Result<String, String> {
let v = self
.with_project(p.project_path.as_deref(), "mcp resolve", |conn, _cfg| {
commands::guide::resolve_value(conn, p.feedback_id, p.run_id)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Record an AI/LLM interaction against a task as an audit-trail entry (shown in the task's AI activity). `kind` is required; model, provider, prompt, response and token counts are optional. Returns a `run_id` you can cite in `resolve`.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = false
)
)]
fn record_run(&self, Parameters(p): Parameters<RecordRunParams>) -> Result<String, String> {
let total = p
.total_tokens
.or_else(|| p.prompt_tokens.zip(p.completion_tokens).map(|(a, b)| a + b));
let v = self
.with_project(p.project_path.as_deref(), "mcp record_run", |conn, _cfg| {
commands::guide::record_run_value(
conn,
&p.id,
&p.kind,
p.model.as_deref(),
p.provider.as_deref(),
p.prompt.as_deref(),
p.response.as_deref(),
p.prompt_tokens,
p.completion_tokens,
total,
)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Remove a link from a task by its link id (shown in `info`).",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = false
)
)]
fn unlink(&self, Parameters(p): Parameters<UnlinkParams>) -> Result<String, String> {
let v = self
.with_project(p.project_path.as_deref(), "mcp unlink", |conn, _cfg| {
commands::annotate::unlink_value(conn, p.link_id)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Remove an annotation from a task by its annotation id (shown in `info`).",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = false
)
)]
fn denotate(&self, Parameters(p): Parameters<DenotateParams>) -> Result<String, String> {
let v = self
.with_project(p.project_path.as_deref(), "mcp denotate", |conn, _cfg| {
commands::annotate::denotate_value(conn, p.annotation_id)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Move a task to another project, given the target project's name.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
fn move_task(&self, Parameters(p): Parameters<MoveTaskParams>) -> Result<String, String> {
let v = self
.with_project(p.project_path.as_deref(), "mcp move_task", |conn, cfg| {
commands::move_task::move_value(conn, cfg, &p.id, &p.project)
})
.map_err(mcp_err)?;
ok_json(v)
}
}