//! Create + guide-editing MCP tools: build a task and shape its guide (steps,
//! notes, anchors, assignment/rationale). Contributes `guide_router`.
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::ErrorData;
use rmcp::{tool, tool_router};
use crate::commands;
use super::params::*;
use super::server::{SaraServer, mcp_err, ok_json};
#[tool_router(router = guide_router, vis = "pub(crate)")]
impl SaraServer {
#[tool(
description = "Start a task in one call: create it, set its assignment/why, register an optional acceptance criterion, and seed the FIRST step — an explicit, agent-run recall of prior art. Returns the task, its criteria, the seeded recall step, and the next cursor (which points AT that recall step). Use this to BEGIN work. begin does NOT recall for you: it directs you to decide what prior knowledge bears on the task and call `recall` yourself as your first step, so prior art is early yet purposeful. An acceptance criterion is optional (a warning is returned if omitted)."
)]
fn begin(&self, Parameters(p): Parameters<BeginParams>) -> Result<String, ErrorData> {
let tags = p.tags.clone().unwrap_or_default();
let files = p.files.clone().unwrap_or_default();
let v = self
.with_project(p.project_path.as_deref(), "mcp begin", |conn, cfg| {
commands::begin::begin_value(
conn,
cfg,
&p.description,
&tags,
&files,
p.project.as_deref(),
p.priority.as_deref(),
p.assignment.as_deref(),
p.rationale.as_deref(),
p.check.as_deref(),
p.verify.as_deref(),
)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(description = "Create a task (never opens the TUI). Returns the new task's id/uuid.")]
fn add(&self, Parameters(p): Parameters<AddParams>) -> Result<String, ErrorData> {
let words = vec![p.description.clone()];
let tags = p.tags.clone().unwrap_or_default();
let annotations = p.annotations.clone().unwrap_or_default();
let links = p.links.clone().unwrap_or_default();
let checks = p.checks.clone().unwrap_or_default();
let depends_on = p.depends_on.clone().unwrap_or_default();
let v = self
.with_project(p.project_path.as_deref(), "mcp add", |conn, cfg| {
commands::add::run_value(
conn,
cfg,
&words,
p.project.as_deref(),
p.priority.as_deref(),
&tags,
p.recur.as_deref(),
&annotations,
&links,
&checks,
&depends_on,
)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Mark a step DONE (ticks the box), recording a result and the current git commit. With neither `n` nor `step_id`, it completes the current step — the first not-done one that `next` returns — so a `next` -> `step_done` round-trip needs no position tracking. Address a specific item by `n` (the `index` returned by check/steps) OR by `step_id` (the rowid check returns). This is how you satisfy an acceptance criterion: pass kind=\"acceptance\" (with its 1-based `n`, or none to tick the first outstanding one). Do NOT call `check` to tick — check only adds."
)]
fn step_done(&self, Parameters(p): Parameters<StepDoneParams>) -> Result<String, ErrorData> {
let v = self
.with_project(p.project_path.as_deref(), "mcp step_done", |conn, _cfg| {
if let Some(step_id) = p.step_id {
commands::guide::step_done_by_id_value(conn, step_id, p.result.as_deref())
} else if let Some(n) = p.n {
commands::guide::step_done_value(
conn,
&p.id,
n,
p.result.as_deref(),
p.kind.as_deref(),
)
} else {
// No `n`/`step_id`: default to the execution cursor (first
// not-done item of `kind`) so `next` -> `step_done` round-trips
// without the caller tracking positions.
commands::guide::step_done_current_value(
conn,
&p.id,
p.result.as_deref(),
p.kind.as_deref(),
)
}
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Add a comment / note to a task (optionally anchored, or an ai finding/decision)."
)]
fn annotate(&self, Parameters(p): Parameters<AnnotateParams>) -> Result<String, ErrorData> {
let words = vec![p.text.clone()];
let v = self
.with_project(p.project_path.as_deref(), "mcp annotate", |conn, _cfg| {
commands::annotate::annotate_value(
conn,
&p.id,
&words,
p.kind.as_deref(),
p.author.as_deref(),
p.on.as_deref(),
p.reconsider.unwrap_or(false),
)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Bulk-ingest a task graph from an inline JSON plan; wires dependencies by plan-local key."
)]
fn plan_import(
&self,
Parameters(p): Parameters<PlanImportParams>,
) -> Result<String, ErrorData> {
let v = self
.with_project(p.project_path.as_deref(), "mcp plan_import", |conn, cfg| {
commands::plan::import_raw(conn, cfg, &p.plan_json)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "ADD a new checklist step (or an acceptance criterion with kind=\"acceptance\") to a task's guide, optionally with an intent note and a verify command. This APPENDS a new item — it does NOT tick an existing one; to mark an item satisfied use `step_done`. Returns the new item's `step_id` (rowid) and `index` (1-based position) — pass either back to step_done/step_remove."
)]
fn check(&self, Parameters(p): Parameters<CheckParams>) -> Result<String, ErrorData> {
let v = self
.with_project(p.project_path.as_deref(), "mcp check", |conn, _cfg| {
commands::guide::check_value(
conn,
&p.id,
&p.text,
p.intent.as_deref(),
p.kind.as_deref(),
p.source.as_deref(),
p.verify.as_deref(),
)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Reopen a previously-completed step (or acceptance criterion) of a task. Address by `n` (1-based, the `index` from steps) OR by `step_id` (the rowid check returns)."
)]
fn step_undone(&self, Parameters(p): Parameters<StepEditParams>) -> Result<String, ErrorData> {
let v = self
.with_project(
p.project_path.as_deref(),
"mcp step_undone",
|conn, _cfg| {
if let Some(step_id) = p.step_id {
commands::guide::step_undone_by_id_value(conn, step_id)
} else {
let n = p.n.ok_or_else(|| {
anyhow::anyhow!("provide `n` (1-based position) or `step_id`")
})?;
commands::guide::step_undone_value(conn, &p.id, n, p.kind.as_deref())
}
},
)
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Delete step N (or acceptance criterion N) from a task's guide; remaining items renumber. Address by `n` (1-based, the `index` from steps) OR by `step_id` (the rowid check returns)."
)]
fn step_remove(&self, Parameters(p): Parameters<StepEditParams>) -> Result<String, ErrorData> {
let v = self
.with_project(
p.project_path.as_deref(),
"mcp step_remove",
|conn, _cfg| {
if let Some(step_id) = p.step_id {
commands::guide::step_remove_by_id_value(conn, step_id)
} else {
let n = p.n.ok_or_else(|| {
anyhow::anyhow!("provide `n` (1-based position) or `step_id`")
})?;
commands::guide::step_remove_value(conn, &p.id, n, p.kind.as_deref())
}
},
)
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(description = "Set a task's assignment (the originating prompt / what to build).")]
fn assignment(&self, Parameters(p): Parameters<GuideTextParams>) -> Result<String, ErrorData> {
let v = self
.with_project(p.project_path.as_deref(), "mcp assignment", |conn, _cfg| {
commands::guide::assignment_value(conn, &p.id, &p.text)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(description = "Set a task's rationale (why it exists / the reasoning behind it).")]
fn rationale(&self, Parameters(p): Parameters<GuideTextParams>) -> Result<String, ErrorData> {
let v = self
.with_project(p.project_path.as_deref(), "mcp rationale", |conn, _cfg| {
commands::guide::rationale_value(conn, &p.id, &p.text)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Attach a file or code anchor to a task (a URL is stored as a link). Anchor metadata: `reason`, `symbol`, `lines` (\"10:57\"), `source`."
)]
fn attach(&self, Parameters(p): Parameters<AttachParams>) -> Result<String, ErrorData> {
let v = self
.with_project(p.project_path.as_deref(), "mcp attach", |conn, _cfg| {
commands::annotate::attach_value(
conn,
&p.id,
&p.path,
p.reason.as_deref(),
p.symbol.as_deref(),
p.lines.as_deref(),
p.source.as_deref(),
)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Save a distilled memory (one key insight, ≤2000 chars). Always run `recall` first to avoid duplicates. Tag memories for reliable retrieval — `tags` shows the existing vocabulary. If the new memory's tags place it inside an established canonical pattern (same-project near-duplicate, or ≥50% tag overlap with a canonical), it is auto-linked `derived_from` that canonical and reported in `auto_derived_from` — so patterns strengthen as instances are learned. An explicit `derived_from`/`supersedes`/`similar_to` to the same memory overrides the auto-link."
)]
fn learn(&self, Parameters(p): Parameters<LearnParams>) -> Result<String, ErrorData> {
let tags = p.tags.unwrap_or_default();
let projects = p.projects.unwrap_or_default();
let tasks = p.tasks.unwrap_or_default();
let files = p.files.unwrap_or_default();
let v = self
.with_project(p.project_path.as_deref(), "mcp learn", |conn, cfg| {
commands::learn::learn_value(
conn,
cfg,
&p.text,
&tags,
&projects,
&tasks,
&files,
false, // auto_files not available via MCP — paths must be explicit
p.force.unwrap_or(false),
&[], // supersedes not yet exposed via MCP
&[], // derived_from not yet exposed via MCP
&[], // similar_to not yet exposed via MCP
)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Archive (forget) a memory by its label, e.g. \"m3\". Use when a memory is stale or wrong. If it's canonical (has derived_from children), they're listed for review, and archived too when cascade=true."
)]
fn forget(&self, Parameters(p): Parameters<ForgetParams>) -> Result<String, ErrorData> {
let v = self
.with_project(p.project_path.as_deref(), "mcp forget", |conn, _cfg| {
commands::forget::forget_value(conn, &p.handle, p.cascade.unwrap_or(false))
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Promote a provisional (auto-synthesised) memory to active after review, e.g. \"m14\". Preserves tags, files, and task links in place."
)]
fn promote(&self, Parameters(p): Parameters<PromoteParams>) -> Result<String, ErrorData> {
let v = self
.with_project(p.project_path.as_deref(), "mcp promote", |conn, _cfg| {
commands::promote::promote_value(conn, &p.handle)
})
.map_err(mcp_err)?;
ok_json(v)
}
#[tool(
description = "Edit a memory in place by label (body/tags/files each optional; tags and files REPLACE the existing set). Preserves label, created date, task links, and memory links — use instead of forget + learn."
)]
fn relearn(&self, Parameters(p): Parameters<RelearnParams>) -> Result<String, ErrorData> {
let v = self
.with_project(p.project_path.as_deref(), "mcp relearn", |conn, _cfg| {
commands::relearn::relearn_value(
conn,
&p.handle,
p.text.as_deref(),
p.tags.as_deref().unwrap_or(&[]),
p.files.as_deref().unwrap_or(&[]),
p.force.unwrap_or(false),
)
})
.map_err(mcp_err)?;
ok_json(v)
}
}