use std::sync::Arc;
use rmcp::{handler::server::wrapper::Parameters, tool, tool_router};
use crate::db::{DbPool, models, queries};
use super::LificMcp;
use super::schemas::*;
impl LificMcp {
pub(crate) fn create_tool_router() -> rmcp::handler::server::router::tool::ToolRouter<Self> {
Self::tool_router()
}
}
pub(crate) fn fmt_issue(i: &models::Issue) -> String {
let mut s = format!(
"{} | {} | {} | {}",
i.identifier, i.status, i.priority, i.title
);
if !i.labels.is_empty() {
s.push_str(&format!(" [{}]", i.labels.join(", ")));
}
if !i.blocks.is_empty() {
s.push_str(&format!(" blocks:{}", i.blocks.join(",")));
}
if !i.blocked_by.is_empty() {
s.push_str(&format!(" blocked_by:{}", i.blocked_by.join(",")));
}
s
}
fn apply_edit(
content: &str,
old: &str,
new: &str,
replace_all: bool,
) -> Result<String, crate::error::LificError> {
if old.is_empty() {
return Err(crate::error::LificError::BadRequest(
"old_string cannot be empty".into(),
));
}
if old == new {
return Err(crate::error::LificError::BadRequest(
"old_string and new_string must differ".into(),
));
}
let count = content.matches(old).count();
match count {
0 => Err(crate::error::LificError::BadRequest(
"old_string not found in content; check exact whitespace and newlines".into(),
)),
1 => Ok(content.replacen(old, new, 1)),
_ if replace_all => Ok(content.replace(old, new)),
n => Err(crate::error::LificError::BadRequest(format!(
"old_string matches {n} locations; provide more surrounding context to make it unique, or set replace_all=true"
))),
}
}
fn resolve_project(db: &Arc<DbPool>, ident: &str) -> Result<i64, String> {
let conn = db.read().map_err(|e| e.to_string())?;
queries::resolve_project_identifier(&conn, ident).map_err(|e| e.to_string())
}
fn resolve_module(db: &Arc<DbPool>, project_id: i64, name: &str) -> Result<i64, String> {
let conn = db.read().map_err(|e| e.to_string())?;
queries::resolve_module_name(&conn, project_id, name).map_err(|e| e.to_string())
}
fn resolve_folder(db: &Arc<DbPool>, project_id: i64, name: &str) -> Result<i64, String> {
let conn = db.read().map_err(|e| e.to_string())?;
queries::resolve_folder_name(&conn, project_id, name).map_err(|e| e.to_string())
}
fn looks_like_page_identifier(s: &str) -> bool {
s.starts_with("DOC-") || s.contains("-DOC-")
}
fn resolve_comment_parent(
mcp: &LificMcp,
identifier: &str,
) -> Result<queries::comments::CommentParent, String> {
if looks_like_page_identifier(identifier) {
let conn = mcp.db.read().map_err(|e| e.to_string())?;
let id = queries::resolve_page_identifier(&conn, identifier).map_err(|e| e.to_string())?;
Ok(queries::comments::CommentParent::Page(id))
} else {
let conn = mcp.db.read().map_err(|e| e.to_string())?;
let id = queries::resolve_identifier(&conn, identifier).map_err(|e| e.to_string())?;
Ok(queries::comments::CommentParent::Issue(id))
}
}
fn append_pagination_hint(out: &mut String, has_more: bool, next_offset: i64) {
if has_more {
out.push_str(&format!(
"\n... more results available — call again with offset={next_offset}\n"
));
}
}
fn truncate_value(v: &str, max: usize) -> String {
let flat = v.replace('\n', " ");
if flat.chars().count() <= max {
flat
} else {
let cut: String = flat.chars().take(max).collect();
format!("{cut}…")
}
}
fn fmt_activity(a: &models::Activity) -> String {
let who = match (&a.actor_display_name, &a.actor_username) {
(Some(d), _) if !d.is_empty() => d.clone(),
(_, Some(u)) => u.clone(),
_ => "system".into(),
};
let agent = if a.actor_is_bot { " (agent)" } else { "" };
let label = a.entity_label.as_deref().unwrap_or("?");
let detail = match a.action.as_str() {
"create" => format!(
"created {} {}: {}",
a.entity_type,
label,
truncate_value(a.new_value.as_deref().unwrap_or(""), 60)
),
"delete" => format!(
"deleted {} {} ({})",
a.entity_type,
label,
truncate_value(a.old_value.as_deref().unwrap_or(""), 60)
),
"update" => format!(
"{} {}: {} → {}",
label,
a.field.as_deref().unwrap_or("?"),
truncate_value(a.old_value.as_deref().unwrap_or("(none)"), 60),
truncate_value(a.new_value.as_deref().unwrap_or("(none)"), 60)
),
"attach" => format!("{} +label {}", label, a.new_value.as_deref().unwrap_or("?")),
"detach" => format!("{} -label {}", label, a.old_value.as_deref().unwrap_or("?")),
"link" => format!(
"{} {} → {}",
label,
a.field.as_deref().unwrap_or("relates_to"),
a.new_value.as_deref().unwrap_or("?")
),
"unlink" => format!(
"{} un-{} → {}",
label,
a.field.as_deref().unwrap_or("relates_to"),
a.old_value.as_deref().unwrap_or("?")
),
other => format!("{label} {other}"),
};
format!("[{}] {}{} via {} — {}", a.ts, who, agent, a.transport, detail)
}
#[tool_router]
impl LificMcp {
#[tool(description = "Search across all issues and pages by text")]
fn search(&self, Parameters(input): Parameters<SearchInput>) -> String {
let project_id = match &input.project {
Some(p) => match resolve_project(&self.db, p) {
Ok(id) => Some(id),
Err(e) => return format!("Error: {e}"),
},
None => None,
};
let limit = input.limit.unwrap_or(20).max(1);
let offset = input.offset.unwrap_or(0).max(0);
match self.read(|conn| {
queries::search(
conn,
&models::SearchQuery {
query: input.query.clone(),
project_id,
result_type: input.result_type.clone(),
sort: input.sort.clone(),
limit: Some(limit + 1),
offset: Some(offset),
},
)
}) {
Ok(results) if results.is_empty() => "No results found.".into(),
Ok(mut results) => {
let has_more = results.len() as i64 > limit;
if has_more {
results.truncate(limit as usize);
}
let mut out = format!("{} results:\n", results.len());
for r in &results {
let ident = r.identifier.as_deref().unwrap_or("");
out.push_str(&format!(
"- [{}] {} {} — {}\n",
r.result_type, ident, r.title, r.snippet
));
}
append_pagination_hint(&mut out, has_more, offset + limit);
out
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Read the audit log: who changed what, when, and through which door (web UI, MCP, API, CLI). Accepts an issue identifier (PRO-42) for that issue's history including comments/labels/relations, a page identifier (PRO-DOC-3), or a bare project identifier (PRO) for the whole project's feed. Entries are newest-first with old → new values per changed field. Perfect for 'what changed while I was gone'."
)]
fn get_activity(&self, Parameters(input): Parameters<GetActivityInput>) -> String {
let limit = input.limit.unwrap_or(30).clamp(1, 200);
let offset = input.offset.unwrap_or(0).max(0);
let ident = input.identifier.trim();
let scope = if looks_like_page_identifier(ident) {
match self.read(|conn| queries::resolve_page_identifier(conn, ident)) {
Ok(id) => queries::activity::ActivityScope::Page(id),
Err(e) => return format!("Error: {e}"),
}
} else if let Ok(id) = self.read(|conn| queries::resolve_identifier(conn, ident)) {
queries::activity::ActivityScope::Issue(id)
} else {
match self.read(|conn| queries::resolve_project_identifier(conn, ident)) {
Ok(id) => queries::activity::ActivityScope::Project(id),
Err(_) => {
return format!(
"Error: '{ident}' is not a known issue, page, or project identifier"
);
}
}
};
match self.read(|conn| {
queries::activity::list_activity(conn, scope, Some(limit), Some(offset))
}) {
Ok(feed) if feed.items.is_empty() && offset == 0 => {
format!("No recorded activity for {ident} yet.")
}
Ok(feed) => {
let mut out = format!("{} activity entries for {ident}:\n", feed.items.len());
for a in &feed.items {
out.push_str(&format!("- {}\n", fmt_activity(a)));
}
append_pagination_hint(&mut out, feed.has_more, offset + limit);
out
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "List issues for a project. Use workable=true for issues with no unresolved blockers."
)]
fn list_issues(&self, Parameters(input): Parameters<ListIssuesInput>) -> String {
let pid = match resolve_project(&self.db, &input.project) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
let module_id = match &input.module {
Some(name) => match resolve_module(&self.db, pid, name) {
Ok(id) => Some(id),
Err(e) => return format!("Error: {e}"),
},
None => None,
};
let limit = input.limit.unwrap_or(50).max(1);
let offset = input.offset.unwrap_or(0).max(0);
match self.read(|conn| {
queries::list_issues(
conn,
&models::ListIssuesQuery {
project_id: Some(pid),
status: input.status.clone(),
priority: input.priority.clone(),
module_id,
label: input.label.clone(),
workable: input.workable,
created_since: input.created_since.clone(),
created_until: input.created_until.clone(),
updated_since: input.updated_since.clone(),
updated_until: input.updated_until.clone(),
order_by: input.order_by.clone(),
order: input.order.clone(),
limit: Some(limit + 1),
offset: Some(offset),
},
)
}) {
Ok(issues) if issues.is_empty() => "No issues found.".into(),
Ok(mut issues) => {
let has_more = issues.len() as i64 > limit;
if has_more {
issues.truncate(limit as usize);
}
let mut out = format!("{} issues:\n", issues.len());
for i in &issues {
out.push_str(&format!("- {}\n", fmt_issue(i)));
}
append_pagination_hint(&mut out, has_more, offset + limit);
out
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Get a single issue by identifier (e.g. LIF-1). Returns full details with relations."
)]
fn get_issue(&self, Parameters(input): Parameters<GetIssueInput>) -> String {
match self.read(|conn| {
let id = queries::resolve_identifier(conn, &input.identifier)?;
let issue = queries::get_issue(conn, id)?;
let module_name = match issue.module_id {
Some(mid) => queries::get_module_name(conn, mid).unwrap_or("unknown".into()),
None => "none".into(),
};
Ok((issue, module_name))
}) {
Ok((issue, module_name)) => {
let mut out = format!(
"{} — {}\nStatus: {} | Priority: {} | Module: {}\n",
issue.identifier, issue.title, issue.status, issue.priority, module_name
);
if !issue.labels.is_empty() {
out.push_str(&format!("Labels: {}\n", issue.labels.join(", ")));
}
if !issue.blocks.is_empty() {
out.push_str(&format!("Blocks: {}\n", issue.blocks.join(", ")));
}
if !issue.blocked_by.is_empty() {
out.push_str(&format!("Blocked by: {}\n", issue.blocked_by.join(", ")));
}
if !issue.relates_to.is_empty() {
out.push_str(&format!("Relates to: {}\n", issue.relates_to.join(", ")));
}
if !issue.description.is_empty() {
out.push_str(&format!("\n{}\n", issue.description));
}
if let Ok(comments) = self.read(|conn| {
queries::comments::list_comments(
conn,
queries::comments::CommentParent::Issue(issue.id),
None,
None,
)
}) && !comments.is_empty()
{
out.push_str(&format!("\n--- Comments ({}) ---\n", comments.len()));
for c in &comments {
out.push_str(&format!(
"[{}] {} ({}): {}\n",
c.created_at, c.author, c.author_display_name, c.content
));
}
}
out
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Export a single issue as markdown. Returns the markdown content.")]
fn export_issue(&self, Parameters(input): Parameters<ExportIssueInput>) -> String {
match self.read(|conn| crate::export::export_issue(conn, &input.identifier)) {
Ok(bundle) => bundle
.files
.into_iter()
.next()
.map(|file| file.content)
.unwrap_or_else(|| "Error: issue export produced no files".into()),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Create a new issue in a project")]
fn create_issue(&self, Parameters(input): Parameters<CreateIssueInput>) -> String {
let pid = match resolve_project(&self.db, &input.project) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
let module_id = match &input.module {
Some(name) => match resolve_module(&self.db, pid, name) {
Ok(id) => Some(id),
Err(e) => return format!("Error: {e}"),
},
None => None,
};
match self.write(|conn| {
queries::create_issue(
conn,
&models::CreateIssue {
project_id: pid,
title: input.title.clone(),
description: input.description.clone().unwrap_or_default(),
status: input.status.clone().unwrap_or("backlog".into()),
priority: input.priority.clone().unwrap_or("none".into()),
module_id,
start_date: None,
target_date: None,
labels: input.labels.clone().unwrap_or_default(),
},
)
}) {
Ok(issue) => format!("Created {}: {}", issue.identifier, issue.title),
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Update an existing issue by identifier. Only provided fields are changed."
)]
fn update_issue(&self, Parameters(input): Parameters<UpdateIssueInput>) -> String {
match self.write(|conn| {
let id = queries::resolve_identifier(conn, &input.identifier)?;
let module_id = match &input.module {
Some(name) => {
let issue = queries::get_issue(conn, id)?;
Some(queries::resolve_module_name(conn, issue.project_id, name)?)
}
None => None,
};
queries::update_issue(
conn,
id,
&models::UpdateIssue {
title: input.title.clone(),
description: input.description.clone(),
status: input.status.clone(),
priority: input.priority.clone(),
module_id,
sort_order: None,
start_date: None,
target_date: None,
labels: input.labels.clone(),
},
)
}) {
Ok(issue) => format!("Updated {}: {}", issue.identifier, fmt_issue(&issue)),
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Edit an issue by replacing an exact string. Targets the description field by default; pass field='title' to edit the title. Fails if old_string is not found or matches multiple places (unless replace_all=true). Cheaper than update_issue for small changes because the agent doesn't have to resend the whole field."
)]
fn edit_issue(&self, Parameters(input): Parameters<EditIssueInput>) -> String {
match self.write(|conn| {
let id = queries::resolve_identifier(conn, &input.identifier)?;
let issue = queries::get_issue(conn, id)?;
let field = input.field.as_deref().unwrap_or("description");
let (current, old_norm, new_norm) = match field {
"description" => (
issue.description.clone(),
queries::unescape_text(&input.old_string),
queries::unescape_text(&input.new_string),
),
"title" => (
issue.title.clone(),
input.old_string.clone(),
input.new_string.clone(),
),
other => {
return Err(crate::error::LificError::BadRequest(format!(
"invalid field '{other}'; expected 'description' or 'title'"
)));
}
};
let updated = apply_edit(
¤t,
&old_norm,
&new_norm,
input.replace_all.unwrap_or(false),
)?;
let mut patch = models::UpdateIssue {
title: None,
description: None,
status: None,
priority: None,
module_id: None,
sort_order: None,
start_date: None,
target_date: None,
labels: None,
};
match field {
"title" => patch.title = Some(updated),
"description" => patch.description = Some(updated),
_ => unreachable!(),
}
queries::update_issue(conn, id, &patch)
}) {
Ok(issue) => format!("Edited {}: {}", issue.identifier, fmt_issue(&issue)),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Get board view of issues grouped by status, priority, or module")]
fn get_board(&self, Parameters(input): Parameters<GetBoardInput>) -> String {
let pid = match resolve_project(&self.db, &input.project) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
const BOARD_CAP: i64 = 500;
match self.read(|conn| {
queries::list_issues(
conn,
&models::ListIssuesQuery {
project_id: Some(pid),
limit: Some(BOARD_CAP + 1),
..Default::default()
},
)
}) {
Ok(mut issues) => {
let truncated = issues.len() as i64 > BOARD_CAP;
if truncated {
issues.truncate(BOARD_CAP as usize);
}
let group_by = input.group_by.as_deref().unwrap_or("status");
let module_names: std::collections::HashMap<i64, String> = if group_by == "module" {
if let Ok(conn) = self.db.read() {
queries::list_modules(&conn, pid)
.unwrap_or_default()
.into_iter()
.map(|m| (m.id, m.name))
.collect()
} else {
std::collections::HashMap::new()
}
} else {
std::collections::HashMap::new()
};
let mut groups: std::collections::BTreeMap<String, Vec<&models::Issue>> =
std::collections::BTreeMap::new();
for issue in &issues {
let key = match group_by {
"priority" => issue.priority.clone(),
"module" => issue
.module_id
.and_then(|m| module_names.get(&m).cloned())
.unwrap_or("unassigned".into()),
_ => issue.status.clone(),
};
groups.entry(key).or_default().push(issue);
}
let rank: fn(&str) -> usize = match group_by {
"priority" => |k| {
["urgent", "high", "medium", "low", "none"]
.iter()
.position(|s| *s == k)
.unwrap_or(usize::MAX)
},
"module" => |_| 0, _ => |k| {
["backlog", "todo", "active", "done", "cancelled"]
.iter()
.position(|s| *s == k)
.unwrap_or(usize::MAX)
},
};
let mut ordered: Vec<(&String, &Vec<&models::Issue>)> = groups.iter().collect();
ordered.sort_by_key(|(key, _)| rank(key));
let mut out = String::new();
if truncated {
out.push_str(&format!(
"warning: board view capped at {BOARD_CAP} issues — older issues are not shown. Use list_issues with offset for full paging.\n\n"
));
}
for (group, items) in ordered {
out.push_str(&format!("── {} ({}) ──\n", group, items.len()));
for i in items {
out.push_str(&format!(" {}\n", fmt_issue(i)));
}
out.push('\n');
}
out
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Link two issues with a relation: blocks, relates_to, or duplicate")]
fn link_issues(&self, Parameters(input): Parameters<LinkIssuesInput>) -> String {
match self.write(|conn| {
let source_id = queries::resolve_identifier(conn, &input.source)?;
let target_id = queries::resolve_identifier(conn, &input.target)?;
queries::link_issues(conn, source_id, target_id, &input.relation_type)
}) {
Ok(()) => format!("{} {} {}", input.source, input.relation_type, input.target),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Remove a relation between two issues")]
fn unlink_issues(&self, Parameters(input): Parameters<UnlinkIssuesInput>) -> String {
match self.write(|conn| {
let source_id = queries::resolve_identifier(conn, &input.source)?;
let target_id = queries::resolve_identifier(conn, &input.target)?;
queries::unlink_issues(conn, source_id, target_id)
}) {
Ok(()) => format!("Unlinked {} and {}", input.source, input.target),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Get a page by identifier (e.g. LIF-DOC-1). Returns full content.")]
fn get_page(&self, Parameters(input): Parameters<GetPageInput>) -> String {
match self.read(|conn| {
let id = queries::resolve_page_identifier(conn, &input.identifier)?;
let page = queries::get_page(conn, id)?;
let folder_name = match page.folder_id {
Some(fid) => Some(queries::get_folder_name(conn, fid)?),
None => None,
};
Ok((page, folder_name))
}) {
Ok((page, folder_name)) => {
let mut out = format!(
"{} — {}\nStatus: {} | Folder: {}\nCreated: {} | Updated: {}\n",
page.identifier,
page.title,
page.status,
folder_name.as_deref().unwrap_or("none"),
page.created_at,
page.updated_at
);
if !page.labels.is_empty() {
out.push_str(&format!("Labels: {}\n", page.labels.join(", ")));
}
if !page.content.is_empty() {
out.push_str(&format!("\n{}\n", page.content));
}
out
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Export a single page as markdown. Returns the markdown content.")]
fn export_page(&self, Parameters(input): Parameters<ExportPageInput>) -> String {
match self.read(|conn| crate::export::export_page(conn, &input.identifier)) {
Ok(bundle) => bundle
.files
.into_iter()
.next()
.map(|file| file.content)
.unwrap_or_else(|| "Error: page export produced no files".into()),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Export an entire project as markdown. Returns exported file paths.")]
fn export_project(&self, Parameters(input): Parameters<ExportProjectInput>) -> String {
match self.read(|conn| crate::export::export_project(conn, &input.project)) {
Ok(bundle) => {
let mut out = format!("{} exported file(s):\n", bundle.files.len());
for file in bundle.files {
out.push_str(&format!("- {}\n", file.path));
}
out
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Create a new page in a project")]
fn create_page(&self, Parameters(input): Parameters<CreatePageInput>) -> String {
let project_id = match &input.project {
Some(p) => match resolve_project(&self.db, p) {
Ok(id) => Some(id),
Err(e) => return format!("Error: {e}"),
},
None => None,
};
let folder_id = match (&input.folder, project_id) {
(Some(name), Some(pid)) => match resolve_folder(&self.db, pid, name) {
Ok(id) => Some(id),
Err(e) => return format!("Error: {e}"),
},
(Some(_), None) => return "Error: folder requires a project".into(),
_ => None,
};
match self.write(|conn| {
queries::create_page(
conn,
&models::CreatePage {
project_id,
folder_id,
title: input.title.clone(),
content: input.content.clone().unwrap_or_default(),
status: input.status.clone().unwrap_or_else(|| "draft".into()),
labels: input.labels.clone().unwrap_or_default(),
},
)
}) {
Ok(page) => format!("Created {}: {}", page.identifier, page.title),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Update a page by identifier. Only provided fields are changed.")]
fn update_page(&self, Parameters(input): Parameters<UpdatePageInput>) -> String {
match self.write(|conn| {
let id = queries::resolve_page_identifier(conn, &input.identifier)?;
let folder_id = match &input.folder {
Some(name) => {
let page = queries::get_page(conn, id)?;
let pid = page.project_id.ok_or_else(|| {
crate::error::LificError::BadRequest(
"page has no project for folder resolution".into(),
)
})?;
Some(queries::resolve_folder_name(conn, pid, name)?)
}
None => None,
};
queries::update_page(
conn,
id,
&models::UpdatePage {
title: input.title.clone(),
content: input.content.clone(),
folder_id: folder_id.map(Some),
sort_order: None,
status: input.status.clone(),
labels: input.labels.clone(),
},
)
}) {
Ok(page) => format!("Updated {}: {}", page.identifier, page.title),
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Edit a page by replacing an exact string. Targets the content field by default; pass field='title' to edit the title. Fails if old_string is not found or matches multiple places (unless replace_all=true). Cheaper than update_page for small changes because the agent doesn't have to resend the whole field."
)]
fn edit_page(&self, Parameters(input): Parameters<EditPageInput>) -> String {
match self.write(|conn| {
let id = queries::resolve_page_identifier(conn, &input.identifier)?;
let page = queries::get_page(conn, id)?;
let field = input.field.as_deref().unwrap_or("content");
let (current, old_norm, new_norm) = match field {
"content" => (
page.content.clone(),
queries::unescape_text(&input.old_string),
queries::unescape_text(&input.new_string),
),
"title" => (
page.title.clone(),
input.old_string.clone(),
input.new_string.clone(),
),
other => {
return Err(crate::error::LificError::BadRequest(format!(
"invalid field '{other}'; expected 'content' or 'title'"
)));
}
};
let updated = apply_edit(
¤t,
&old_norm,
&new_norm,
input.replace_all.unwrap_or(false),
)?;
let mut patch = models::UpdatePage {
title: None,
content: None,
folder_id: None,
sort_order: None,
status: None,
labels: None,
};
match field {
"title" => patch.title = Some(updated),
"content" => patch.content = Some(updated),
_ => unreachable!(),
}
queries::update_page(conn, id, &patch)
}) {
Ok(page) => format!("Edited {}: {}", page.identifier, page.title),
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Delete any resource by type and identifier. Types: issue, page, project, module, label, folder."
)]
fn delete(&self, Parameters(input): Parameters<DeleteInput>) -> String {
match input.resource_type.as_str() {
"issue" => match self.write(|conn| {
let id = queries::resolve_identifier(conn, &input.identifier)?;
queries::delete_issue(conn, id)
}) {
Ok(()) => format!("Deleted issue {}", input.identifier),
Err(e) => format!("Error: {e}"),
},
"page" => match self.write(|conn| {
let id = queries::resolve_page_identifier(conn, &input.identifier)?;
queries::delete_page(conn, id)
}) {
Ok(()) => format!("Deleted page {}", input.identifier),
Err(e) => format!("Error: {e}"),
},
"project" => match self.write(|conn| {
let id = queries::resolve_project_identifier(conn, &input.identifier)?;
queries::delete_project(conn, id)
}) {
Ok(()) => format!("Deleted project {}", input.identifier),
Err(e) => format!("Error: {e}"),
},
"module" | "label" | "folder" => {
let Some(ref proj) = input.project else {
return format!(
"Error: project required to delete {} by name",
input.resource_type
);
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
let result = match input.resource_type.as_str() {
"module" => self.write(|conn| {
let id = queries::resolve_module_name(conn, pid, &input.identifier)?;
queries::delete_module(conn, id)
}),
"label" => self.write(|conn| {
let id = queries::resolve_label_name(conn, pid, &input.identifier)?;
queries::delete_label(conn, id)
}),
"folder" => self.write(|conn| {
let id = queries::resolve_folder_name(conn, pid, &input.identifier)?;
queries::delete_folder(conn, id)
}),
_ => unreachable!(),
};
match result {
Ok(()) => format!("Deleted {} '{}'", input.resource_type, input.identifier),
Err(e) => format!("Error: {e}"),
}
}
other => format!(
"Unknown type '{other}'. Use issue, page, project, module, label, or folder."
),
}
}
#[tool(
description = "List resources by type: project, module, label, folder, page, or issue. Most types need a project identifier."
)]
fn list_resources(&self, Parameters(input): Parameters<ListResourcesInput>) -> String {
match input.resource_type.as_str() {
"project" => match self.read(queries::list_projects) {
Ok(ps) => {
let mut out = format!("{} projects:\n", ps.len());
for p in &ps {
out.push_str(&format!("- {} | {}", p.identifier, p.name));
if !p.description.is_empty() {
out.push_str(&format!(" — {}", p.description));
}
out.push('\n');
}
out
}
Err(e) => format!("Error: {e}"),
},
"issue" => {
let Some(ref proj) = input.project else {
return "Error: project required".into();
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
let limit = input.limit.unwrap_or(100).max(1);
let offset = input.offset.unwrap_or(0).max(0);
match self.read(|conn| {
queries::list_issues(
conn,
&models::ListIssuesQuery {
project_id: Some(pid),
limit: Some(limit + 1),
offset: Some(offset),
..Default::default()
},
)
}) {
Ok(mut issues) => {
let has_more = issues.len() as i64 > limit;
if has_more {
issues.truncate(limit as usize);
}
let mut out =
format!("{} issues (use list_issues for filtering):\n", issues.len());
for i in &issues {
out.push_str(&format!(
"- {} | {} | {}\n",
i.identifier, i.status, i.title
));
}
append_pagination_hint(&mut out, has_more, offset + limit);
out
}
Err(e) => format!("Error: {e}"),
}
}
"page" => {
let project_id = match &input.project {
Some(p) => match resolve_project(&self.db, p) {
Ok(id) => Some(id),
Err(e) => return format!("Error: {e}"),
},
None => None,
};
let folder_id = match (&input.folder, project_id) {
(Some(name), Some(pid)) => match resolve_folder(&self.db, pid, name) {
Ok(id) => Some(id),
Err(e) => return format!("Error: {e}"),
},
_ => None,
};
let label = input.label.as_deref();
let status = input.status.as_deref();
let order_by = input.order_by.as_deref();
let order = input.order.as_deref();
match self.read(|conn| {
let pages =
queries::list_pages(conn, project_id, folder_id, label, status, order_by, order)?;
let folder_names: std::collections::HashMap<i64, String> = match project_id {
Some(pid) => queries::list_folders(conn, pid)?
.into_iter()
.map(|f| (f.id, f.name))
.collect(),
None => std::collections::HashMap::new(),
};
Ok((pages, folder_names))
}) {
Ok((pages, _)) if pages.is_empty() => "No pages found.".into(),
Ok((pages, folder_names)) => {
let mut out = format!("{} pages:\n", pages.len());
for p in &pages {
let labels = if p.labels.is_empty() {
String::new()
} else {
format!(" [{}]", p.labels.join(", "))
};
let folder = p
.folder_id
.and_then(|fid| folder_names.get(&fid))
.map(|name| format!(" (folder: {name})"))
.unwrap_or_default();
let updated = p.updated_at.split(' ').next().unwrap_or(&p.updated_at);
out.push_str(&format!(
"- {} | {} | {}{}{} — updated {}\n",
p.identifier, p.status, p.title, labels, folder, updated
));
}
out
}
Err(e) => format!("Error: {e}"),
}
}
"module" => {
let Some(ref proj) = input.project else {
return "Error: project required".into();
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
match self.read(|conn| queries::list_modules(conn, pid)) {
Ok(ms) => {
let mut out = format!("{} modules:\n", ms.len());
for m in &ms {
out.push_str(&format!("- {} ({})", m.name, m.status));
if !m.description.is_empty() {
out.push_str(&format!(" — {}", m.description));
}
out.push('\n');
}
out
}
Err(e) => format!("Error: {e}"),
}
}
"label" => {
let Some(ref proj) = input.project else {
return "Error: project required".into();
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
match self.read(|conn| queries::list_labels(conn, pid)) {
Ok(ls) => {
let mut out = format!("{} labels:\n", ls.len());
for l in &ls {
out.push_str(&format!("- {} ({})\n", l.name, l.color));
}
out
}
Err(e) => format!("Error: {e}"),
}
}
"folder" => {
let Some(ref proj) = input.project else {
return "Error: project required".into();
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
match self.read(|conn| queries::list_folders(conn, pid)) {
Ok(fs) => {
let mut out = format!("{} folders:\n", fs.len());
for f in &fs {
out.push_str(&format!("- [{}] {}\n", f.id, f.name));
}
out
}
Err(e) => format!("Error: {e}"),
}
}
other => format!(
"Unknown type '{other}'. Use project, module, label, folder, page, or issue."
),
}
}
#[tool(
description = "Create or update a resource (project, module, label, folder). Use the delete tool for deletion."
)]
fn manage_resource(&self, Parameters(input): Parameters<ManageResourceInput>) -> String {
match (input.resource_type.as_str(), input.action.as_str()) {
("project", "create") => {
let Some(ref name) = input.name else {
return "Error: name required".into();
};
let Some(ref ident) = input.identifier else {
return "Error: identifier required".into();
};
let lead_user_id = super::current_auth_user().and_then(|u| {
self.read(|conn| {
Ok(conn
.query_row(
"SELECT 1 FROM users WHERE id = ?1",
rusqlite::params![u.id],
|_| Ok(()),
)
.is_ok())
})
.ok()
.filter(|exists| *exists)
.map(|_| u.id)
});
match self.write(|conn| {
queries::create_project(
conn,
&models::CreateProject {
name: name.clone(),
identifier: ident.clone(),
description: input.description.clone().unwrap_or_default(),
emoji: None,
lead_user_id,
},
)
}) {
Ok(p) => format!("Created project {} | {}", p.identifier, p.name),
Err(e) => format!("Error: {e}"),
}
}
("project", "update") => {
let Some(ref proj) = input.project else {
return "Error: project identifier required".into();
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
match self.write(|conn| {
queries::update_project(
conn,
pid,
&models::UpdateProject {
name: input.name.clone(),
identifier: input.identifier.clone(),
description: input.description.clone(),
emoji: None,
lead_user_id: None,
},
)
}) {
Ok(p) => format!("Updated project {} | {}", p.identifier, p.name),
Err(e) => format!("Error: {e}"),
}
}
("module", "create") => {
let Some(ref proj) = input.project else {
return "Error: project required".into();
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
let Some(ref name) = input.name else {
return "Error: name required".into();
};
match self.write(|conn| {
queries::create_module(
conn,
&models::CreateModule {
project_id: pid,
name: name.clone(),
description: input.description.clone().unwrap_or_default(),
status: input.status.clone().unwrap_or("active".into()),
emoji: None,
},
)
}) {
Ok(m) => format!("Created module [{}]: {}", m.id, m.name),
Err(e) => format!("Error: {e}"),
}
}
("module", "update") => {
let Some(ref proj) = input.project else {
return "Error: project required".into();
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
let Some(ref current) = input.current_name else {
return "Error: current_name required to identify module".into();
};
let mid = match resolve_module(&self.db, pid, current) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
match self.write(|conn| {
queries::update_module(
conn,
mid,
&models::UpdateModule {
name: input.name.clone(),
description: input.description.clone(),
status: input.status.clone(),
emoji: None,
},
)
}) {
Ok(m) => format!("Updated module: {}", m.name),
Err(e) => format!("Error: {e}"),
}
}
("label", "create") => {
let Some(ref proj) = input.project else {
return "Error: project required".into();
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
let Some(ref name) = input.name else {
return "Error: name required".into();
};
match self.write(|conn| {
queries::create_label(
conn,
&models::CreateLabel {
project_id: pid,
name: name.clone(),
color: input.color.clone().unwrap_or("#6B7280".into()),
},
)
}) {
Ok(l) => format!("Created label: {} ({})", l.name, l.color),
Err(e) => format!("Error: {e}"),
}
}
("label", "update") => {
let Some(ref proj) = input.project else {
return "Error: project required".into();
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
let Some(ref current) = input.current_name else {
return "Error: current_name required to identify label".into();
};
let lid = match self.read(|conn| queries::resolve_label_name(conn, pid, current)) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
match self.write(|conn| {
queries::update_label(
conn,
lid,
&models::UpdateLabel {
name: input.name.clone(),
color: input.color.clone(),
},
)
}) {
Ok(l) => format!("Updated label: {} ({})", l.name, l.color),
Err(e) => format!("Error: {e}"),
}
}
("folder", "create") => {
let Some(ref proj) = input.project else {
return "Error: project required".into();
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
let Some(ref name) = input.name else {
return "Error: name required".into();
};
match self.write(|conn| {
queries::create_folder(
conn,
&models::CreateFolder {
project_id: pid,
parent_id: None,
name: name.clone(),
},
)
}) {
Ok(f) => format!("Created folder [{}]: {}", f.id, f.name),
Err(e) => format!("Error: {e}"),
}
}
("folder", "update") => {
let Some(ref proj) = input.project else {
return "Error: project required".into();
};
let pid = match resolve_project(&self.db, proj) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
let Some(ref current) = input.current_name else {
return "Error: current_name required to identify folder".into();
};
let fid = match self.read(|conn| queries::resolve_folder_name(conn, pid, current)) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
match self.write(|conn| {
queries::update_folder(
conn,
fid,
&models::UpdateFolder {
name: input.name.clone(),
},
)
}) {
Ok(f) => format!("Updated folder: {}", f.name),
Err(e) => format!("Error: {e}"),
}
}
(rt, act) => format!(
"Unsupported: {rt}/{act}. Types: project, module, label, folder. Actions: create, update."
),
}
}
#[tool(
description = "Add a comment to an issue or page. Accepts an issue identifier (LIF-42) or a page identifier (LIF-DOC-3 or DOC-3 for workspace pages). The author is the user who owns the API key authenticating this MCP session."
)]
fn add_comment(&self, Parameters(input): Parameters<AddCommentInput>) -> String {
let parent = match resolve_comment_parent(self, &input.identifier) {
Ok(p) => p,
Err(e) => return format!("Error: {e}"),
};
let user_id = match super::current_auth_user() {
Some(u) => u.id,
None => {
match self.read(queries::users::first_admin) {
Ok(Some(admin)) => admin.id,
Ok(None) => {
return "Error: no admin user exists to attribute comments to.".into();
}
Err(e) => return format!("Error: {e}"),
}
}
};
match self.write(|conn| {
queries::comments::create_comment(conn, parent, user_id, &input.content)
}) {
Ok(c) => format!(
"Comment #{} added to {} by {} at {}",
c.id, input.identifier, c.author, c.created_at
),
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "List comments on an issue or page. Accepts an issue identifier (LIF-42) or a page identifier (LIF-DOC-3 or DOC-3 for workspace pages)."
)]
fn list_comments(&self, Parameters(input): Parameters<ListCommentsInput>) -> String {
let parent = match resolve_comment_parent(self, &input.identifier) {
Ok(p) => p,
Err(e) => return format!("Error: {e}"),
};
match self.read(|conn| {
queries::comments::list_comments(
conn,
parent,
input.author.as_deref(),
input.order.as_deref(),
)
}) {
Ok(comments) if comments.is_empty() => {
format!("No comments on {}.", input.identifier)
}
Ok(comments) => {
let mut out = format!("{} comment(s) on {}:\n", comments.len(), input.identifier);
for c in &comments {
out.push_str(&format!(
"[{}] {} ({}): {}\n",
c.created_at, c.author, c.author_display_name, c.content
));
}
out
}
Err(e) => format!("Error: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rmcp::handler::server::wrapper::Parameters;
fn mcp() -> LificMcp {
let db = crate::db::open_memory().expect("test db");
LificMcp::new(db)
}
fn seed_project(mcp: &LificMcp, name: &str, ident: &str) -> String {
let result = mcp.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "create".into(),
name: Some(name.into()),
identifier: Some(ident.into()),
description: None,
project: None,
current_name: None,
status: None,
color: None,
}));
assert!(result.starts_with("Created project"), "got: {result}");
ident.to_string()
}
fn seed_issue(mcp: &LificMcp, project: &str, title: &str) -> String {
let result = mcp.create_issue(Parameters(CreateIssueInput {
project: project.into(),
title: title.into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
}));
assert!(result.starts_with("Created"), "got: {result}");
result
}
#[test]
fn manage_create_project() {
let m = mcp();
let result = seed_project(&m, "Alpha", "ALP");
assert_eq!(result, "ALP");
}
#[test]
fn manage_update_project() {
let m = mcp();
seed_project(&m, "Old", "UPD");
let result = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "update".into(),
project: Some("UPD".into()),
name: Some("New Name".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
}));
assert!(result.contains("New Name"), "got: {result}");
}
#[test]
fn manage_create_module() {
let m = mcp();
seed_project(&m, "Test", "MOD");
let result = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "module".into(),
action: "create".into(),
project: Some("MOD".into()),
name: Some("Backend".into()),
description: Some("Server-side logic".into()),
identifier: None,
current_name: None,
status: None,
color: None,
}));
assert!(result.contains("Backend"), "got: {result}");
}
#[test]
fn manage_create_label() {
let m = mcp();
seed_project(&m, "Test", "LBL");
let result = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "label".into(),
action: "create".into(),
project: Some("LBL".into()),
name: Some("bug".into()),
color: Some("#EF4444".into()),
identifier: None,
description: None,
current_name: None,
status: None,
}));
assert!(result.contains("bug"), "got: {result}");
assert!(result.contains("#EF4444"), "got: {result}");
}
#[test]
fn manage_create_folder() {
let m = mcp();
seed_project(&m, "Test", "FLD");
let result = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "folder".into(),
action: "create".into(),
project: Some("FLD".into()),
name: Some("Docs".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
}));
assert!(result.contains("Docs"), "got: {result}");
}
#[test]
fn manage_missing_name_errors() {
let m = mcp();
let result = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "create".into(),
name: None,
identifier: Some("X".into()),
description: None,
project: None,
current_name: None,
status: None,
color: None,
}));
assert!(result.contains("name required"), "got: {result}");
}
#[test]
fn manage_unknown_type() {
let m = mcp();
let result = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "widget".into(),
action: "create".into(),
name: None,
identifier: None,
description: None,
project: None,
current_name: None,
status: None,
color: None,
}));
assert!(result.contains("Unsupported"), "got: {result}");
}
#[test]
fn issue_create_and_get() {
let m = mcp();
seed_project(&m, "Test", "TST");
let created = seed_issue(&m, "TST", "First issue");
assert!(created.contains("TST-1"), "got: {created}");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "TST-1".into(),
}));
assert!(detail.contains("First issue"), "got: {detail}");
assert!(detail.contains("backlog"), "got: {detail}");
}
#[test]
fn issue_create_with_options() {
let m = mcp();
seed_project(&m, "Test", "OPT");
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "label".into(),
action: "create".into(),
project: Some("OPT".into()),
name: Some("feature".into()),
color: None,
identifier: None,
description: None,
current_name: None,
status: None,
}));
let result = m.create_issue(Parameters(CreateIssueInput {
project: "OPT".into(),
title: "Detailed issue".into(),
description: Some("Some markdown".into()),
status: Some("todo".into()),
priority: Some("high".into()),
module: None,
labels: Some(vec!["feature".into()]),
}));
assert!(result.contains("OPT-1"), "got: {result}");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "OPT-1".into(),
}));
assert!(detail.contains("high"), "got: {detail}");
assert!(detail.contains("todo"), "got: {detail}");
assert!(detail.contains("feature"), "got: {detail}");
assert!(detail.contains("Some markdown"), "got: {detail}");
}
#[test]
fn issue_update() {
let m = mcp();
seed_project(&m, "Test", "UPI");
seed_issue(&m, "UPI", "Original");
let result = m.update_issue(Parameters(UpdateIssueInput {
identifier: "UPI-1".into(),
title: Some("Renamed".into()),
status: Some("active".into()),
priority: Some("urgent".into()),
description: None,
module: None,
labels: None,
}));
assert!(result.contains("Renamed"), "got: {result}");
assert!(result.contains("active"), "got: {result}");
assert!(result.contains("urgent"), "got: {result}");
}
#[test]
fn issue_delete() {
let m = mcp();
seed_project(&m, "Test", "DEL");
seed_issue(&m, "DEL", "Doomed");
let result = m.delete(Parameters(DeleteInput {
resource_type: "issue".into(),
identifier: "DEL-1".into(),
project: None,
}));
assert!(result.contains("Deleted issue"), "got: {result}");
let get = m.get_issue(Parameters(GetIssueInput {
identifier: "DEL-1".into(),
}));
assert!(get.starts_with("Error"), "got: {get}");
}
#[test]
fn get_nonexistent_issue_errors() {
let m = mcp();
let result = m.get_issue(Parameters(GetIssueInput {
identifier: "NOPE-999".into(),
}));
assert!(result.starts_with("Error"), "got: {result}");
}
#[test]
fn list_issues_with_filters() {
let m = mcp();
seed_project(&m, "Test", "LST");
m.create_issue(Parameters(CreateIssueInput {
project: "LST".into(),
title: "Todo one".into(),
status: Some("todo".into()),
priority: Some("high".into()),
description: None,
module: None,
labels: None,
}));
m.create_issue(Parameters(CreateIssueInput {
project: "LST".into(),
title: "Active one".into(),
status: Some("active".into()),
priority: Some("low".into()),
description: None,
module: None,
labels: None,
}));
let result = m.list_issues(Parameters(ListIssuesInput {
project: "LST".into(),
status: Some("todo".into()),
priority: None,
module: None,
label: None,
workable: None,
limit: None,
offset: None,
..Default::default()
}));
assert!(result.contains("1 issues"), "got: {result}");
assert!(result.contains("Todo one"), "got: {result}");
}
#[test]
fn list_issues_empty() {
let m = mcp();
seed_project(&m, "Empty", "EMP");
let result = m.list_issues(Parameters(ListIssuesInput {
project: "EMP".into(),
status: None,
priority: None,
module: None,
label: None,
workable: None,
limit: None,
offset: None,
..Default::default()
}));
assert_eq!(result, "No issues found.");
}
#[test]
fn list_issues_bad_project_errors() {
let m = mcp();
let result = m.list_issues(Parameters(ListIssuesInput {
project: "NOPE".into(),
status: None,
priority: None,
module: None,
label: None,
workable: None,
limit: None,
offset: None,
..Default::default()
}));
assert!(result.starts_with("Error"), "got: {result}");
}
#[test]
fn list_issues_pagination_emits_has_more_hint() {
let m = mcp();
seed_project(&m, "Pages", "PAG");
for i in 0..5 {
seed_issue(&m, "PAG", &format!("Issue {i}"));
}
let page1 = m.list_issues(Parameters(ListIssuesInput {
project: "PAG".into(),
status: None,
priority: None,
module: None,
label: None,
workable: None,
limit: Some(2),
offset: None,
..Default::default()
}));
assert!(page1.contains("2 issues"), "got: {page1}");
assert!(
page1.contains("offset=2"),
"expected has_more hint, got: {page1}"
);
let page2 = m.list_issues(Parameters(ListIssuesInput {
project: "PAG".into(),
status: None,
priority: None,
module: None,
label: None,
workable: None,
limit: Some(2),
offset: Some(2),
..Default::default()
}));
assert!(page2.contains("2 issues"), "got: {page2}");
assert!(
page2.contains("offset=4"),
"expected has_more hint, got: {page2}"
);
let page3 = m.list_issues(Parameters(ListIssuesInput {
project: "PAG".into(),
status: None,
priority: None,
module: None,
label: None,
workable: None,
limit: Some(2),
offset: Some(4),
..Default::default()
}));
assert!(page3.contains("1 issues"), "got: {page3}");
assert!(
!page3.contains("more results available"),
"should NOT have hint on last page, got: {page3}"
);
}
#[test]
fn list_issues_no_hint_when_under_limit() {
let m = mcp();
seed_project(&m, "Small", "SML");
seed_issue(&m, "SML", "Only one");
let result = m.list_issues(Parameters(ListIssuesInput {
project: "SML".into(),
status: None,
priority: None,
module: None,
label: None,
workable: None,
limit: Some(10),
offset: None,
..Default::default()
}));
assert!(result.contains("1 issues"), "got: {result}");
assert!(
!result.contains("more results available"),
"got: {result}"
);
}
#[test]
fn link_and_unlink_issues() {
let m = mcp();
seed_project(&m, "Test", "LNK");
seed_issue(&m, "LNK", "Blocker");
seed_issue(&m, "LNK", "Blocked");
let result = m.link_issues(Parameters(LinkIssuesInput {
source: "LNK-1".into(),
target: "LNK-2".into(),
relation_type: "blocks".into(),
}));
assert!(result.contains("blocks"), "got: {result}");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "LNK-1".into(),
}));
assert!(detail.contains("Blocks"), "got: {detail}");
let result = m.unlink_issues(Parameters(UnlinkIssuesInput {
source: "LNK-1".into(),
target: "LNK-2".into(),
}));
assert!(result.contains("Unlinked"), "got: {result}");
}
#[test]
fn board_groups_by_status() {
let m = mcp();
seed_project(&m, "Test", "BRD");
m.create_issue(Parameters(CreateIssueInput {
project: "BRD".into(),
title: "A".into(),
status: Some("todo".into()),
description: None,
priority: None,
module: None,
labels: None,
}));
m.create_issue(Parameters(CreateIssueInput {
project: "BRD".into(),
title: "B".into(),
status: Some("active".into()),
description: None,
priority: None,
module: None,
labels: None,
}));
let result = m.get_board(Parameters(GetBoardInput {
project: "BRD".into(),
group_by: None,
}));
assert!(result.contains("todo"), "got: {result}");
assert!(result.contains("active"), "got: {result}");
}
#[test]
fn board_status_columns_in_workflow_order() {
let m = mcp();
seed_project(&m, "Board Order", "BRO");
for status in ["done", "active", "backlog", "todo", "cancelled"] {
m.create_issue(Parameters(CreateIssueInput {
project: "BRO".into(),
title: format!("issue {status}"),
status: Some(status.into()),
description: None,
priority: None,
module: None,
labels: None,
}));
}
let result = m.get_board(Parameters(GetBoardInput {
project: "BRO".into(),
group_by: None,
}));
let pos = |s: &str| {
result
.find(&format!("── {s} ("))
.unwrap_or_else(|| panic!("missing column {s}: {result}"))
};
assert!(pos("backlog") < pos("todo"), "got: {result}");
assert!(pos("todo") < pos("active"), "got: {result}");
assert!(pos("active") < pos("done"), "got: {result}");
assert!(pos("done") < pos("cancelled"), "got: {result}");
}
#[test]
fn board_priority_columns_in_severity_order() {
let m = mcp();
seed_project(&m, "Board Prio", "BRP");
for priority in ["none", "medium", "urgent", "low", "high"] {
m.create_issue(Parameters(CreateIssueInput {
project: "BRP".into(),
title: format!("issue {priority}"),
status: None,
description: None,
priority: Some(priority.into()),
module: None,
labels: None,
}));
}
let result = m.get_board(Parameters(GetBoardInput {
project: "BRP".into(),
group_by: Some("priority".into()),
}));
let pos = |s: &str| {
result
.find(&format!("── {s} ("))
.unwrap_or_else(|| panic!("missing column {s}: {result}"))
};
assert!(pos("urgent") < pos("high"), "got: {result}");
assert!(pos("high") < pos("medium"), "got: {result}");
assert!(pos("medium") < pos("low"), "got: {result}");
assert!(pos("low") < pos("none"), "got: {result}");
}
#[test]
fn page_create_get_update() {
let m = mcp();
seed_project(&m, "Test", "PG");
let created = m.create_page(Parameters(CreatePageInput {
project: Some("PG".into()),
title: "Design Doc".into(),
content: Some("# Overview\nSome content".into()),
folder: None,
status: None,
labels: None,
}));
assert!(created.contains("PG-DOC-1"), "got: {created}");
let detail = m.get_page(Parameters(GetPageInput {
identifier: "PG-DOC-1".into(),
}));
assert!(detail.contains("Design Doc"), "got: {detail}");
assert!(detail.contains("# Overview"), "got: {detail}");
let updated = m.update_page(Parameters(UpdatePageInput {
identifier: "PG-DOC-1".into(),
title: Some("Updated Doc".into()),
content: None,
folder: None,
status: None,
labels: None,
}));
assert!(updated.contains("Updated Doc"), "got: {updated}");
}
#[test]
fn workspace_page_no_project() {
let m = mcp();
let created = m.create_page(Parameters(CreatePageInput {
project: None,
title: "Global Note".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
assert!(created.contains("DOC-"), "got: {created}");
}
#[test]
fn page_delete() {
let m = mcp();
seed_project(&m, "Test", "PGD");
m.create_page(Parameters(CreatePageInput {
project: Some("PGD".into()),
title: "Temp".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
let result = m.delete(Parameters(DeleteInput {
resource_type: "page".into(),
identifier: "PGD-DOC-1".into(),
project: None,
}));
assert!(result.contains("Deleted page"), "got: {result}");
}
#[test]
fn search_finds_issue() {
let m = mcp();
seed_project(&m, "Test", "SRC");
seed_issue(&m, "SRC", "Unique searchterm xyz");
let result = m.search(Parameters(SearchInput {
query: "searchterm".into(),
project: None,
limit: None,
..Default::default()
}));
assert!(result.contains("1 results"), "got: {result}");
assert!(result.contains("searchterm"), "got: {result}");
}
#[test]
fn search_no_results() {
let m = mcp();
let result = m.search(Parameters(SearchInput {
query: "nonexistent_gibberish_zzz".into(),
project: None,
limit: None,
..Default::default()
}));
assert_eq!(result, "No results found.");
}
#[test]
fn list_resources_projects() {
let m = mcp();
seed_project(&m, "Alpha", "AAA");
seed_project(&m, "Beta", "BBB");
let result = m.list_resources(Parameters(ListResourcesInput {
resource_type: "project".into(),
project: None,
folder: None,
label: None,
limit: None,
offset: None,
..Default::default()
}));
assert!(result.contains("2 projects"), "got: {result}");
assert!(result.contains("AAA"), "got: {result}");
assert!(result.contains("BBB"), "got: {result}");
}
#[test]
fn list_resources_requires_project() {
let m = mcp();
for rt in ["module", "label", "folder", "issue"] {
let result = m.list_resources(Parameters(ListResourcesInput {
resource_type: rt.into(),
project: None,
folder: None,
label: None,
limit: None,
offset: None,
..Default::default()
}));
assert!(result.contains("project required"), "{rt} got: {result}");
}
}
#[test]
fn list_resources_unknown_type() {
let m = mcp();
let result = m.list_resources(Parameters(ListResourcesInput {
resource_type: "widget".into(),
project: None,
folder: None,
label: None,
limit: None,
offset: None,
..Default::default()
}));
assert!(result.contains("Unknown type"), "got: {result}");
}
#[test]
fn list_resources_issues_pagination() {
let m = mcp();
seed_project(&m, "Bulk", "BLK");
for i in 0..4 {
seed_issue(&m, "BLK", &format!("Issue {i}"));
}
let result = m.list_resources(Parameters(ListResourcesInput {
resource_type: "issue".into(),
project: Some("BLK".into()),
folder: None,
label: None,
limit: Some(2),
offset: None,
..Default::default()
}));
assert!(result.contains("2 issues"), "got: {result}");
assert!(result.contains("offset=2"), "got: {result}");
}
#[test]
fn delete_project() {
let m = mcp();
seed_project(&m, "Doomed", "DPJ");
let result = m.delete(Parameters(DeleteInput {
resource_type: "project".into(),
identifier: "DPJ".into(),
project: None,
}));
assert!(result.contains("Deleted project"), "got: {result}");
}
#[test]
fn delete_module_requires_project() {
let m = mcp();
let result = m.delete(Parameters(DeleteInput {
resource_type: "module".into(),
identifier: "Backend".into(),
project: None,
}));
assert!(result.contains("project required"), "got: {result}");
}
#[test]
fn delete_unknown_type() {
let m = mcp();
let result = m.delete(Parameters(DeleteInput {
resource_type: "widget".into(),
identifier: "x".into(),
project: None,
}));
assert!(result.contains("Unknown type"), "got: {result}");
}
#[test]
fn manage_update_label() {
let m = mcp();
seed_project(&m, "Test", "UPL");
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "label".into(),
action: "create".into(),
project: Some("UPL".into()),
name: Some("bug".into()),
color: Some("#EF4444".into()),
identifier: None,
description: None,
current_name: None,
status: None,
}));
let result = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "label".into(),
action: "update".into(),
project: Some("UPL".into()),
current_name: Some("bug".into()),
name: Some("defect".into()),
color: Some("#FF0000".into()),
identifier: None,
description: None,
status: None,
}));
assert!(result.contains("defect"), "got: {result}");
assert!(result.contains("#FF0000"), "got: {result}");
}
#[test]
fn manage_update_folder() {
let m = mcp();
seed_project(&m, "Test", "UPF");
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "folder".into(),
action: "create".into(),
project: Some("UPF".into()),
name: Some("Docs".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
}));
let result = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "folder".into(),
action: "update".into(),
project: Some("UPF".into()),
current_name: Some("Docs".into()),
name: Some("Documentation".into()),
identifier: None,
description: None,
status: None,
color: None,
}));
assert!(result.contains("Documentation"), "got: {result}");
}
#[test]
fn fmt_issue_includes_relations() {
let issue = models::Issue {
id: 1,
project_id: 1,
sequence: 1,
identifier: "T-1".into(),
title: "Test".into(),
description: String::new(),
status: "todo".into(),
priority: "high".into(),
module_id: None,
sort_order: 0.0,
start_date: None,
target_date: None,
created_at: String::new(),
updated_at: String::new(),
labels: vec!["bug".into()],
blocks: vec!["T-2".into()],
blocked_by: vec![],
relates_to: vec![],
};
let s = fmt_issue(&issue);
assert!(s.contains("[bug]"), "got: {s}");
assert!(s.contains("blocks:T-2"), "got: {s}");
}
fn seed_user(mcp: &LificMcp) {
let conn = mcp.db.write().unwrap();
let user = crate::db::queries::users::create_user(
&conn,
&models::CreateUser {
username: "testuser".into(),
email: "test@test.com".into(),
password: "testpassword1".into(),
display_name: Some("Test User".into()),
is_admin: true,
is_bot: false,
},
)
.unwrap();
*crate::mcp::MCP_REQUEST_USER
.lock()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner()) = Some(models::AuthUser {
id: user.id,
username: user.username.clone(),
display_name: user.display_name,
is_admin: user.is_admin,
});
}
#[test]
fn add_and_list_comments() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Test issue");
seed_user(&m);
let result = m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "Hello from MCP".into(),
}));
assert!(result.starts_with("Comment #"), "got: {result}");
assert!(result.contains("testuser"), "got: {result}");
assert!(
!result.contains("Hello from MCP"),
"response should not echo content back: {result}"
);
let result = m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "Second comment".into(),
}));
assert!(result.starts_with("Comment #"), "got: {result}");
let result = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
..Default::default()
}));
assert!(result.contains("2 comment(s)"), "got: {result}");
assert!(result.contains("Hello from MCP"), "got: {result}");
assert!(result.contains("Second comment"), "got: {result}");
}
#[test]
fn get_issue_includes_comments() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Commented issue");
seed_user(&m);
m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "Visible in get_issue".into(),
}));
let result = m.get_issue(Parameters(GetIssueInput {
identifier: "PRJ-1".into(),
}));
assert!(result.contains("Comments (1)"), "got: {result}");
assert!(result.contains("Visible in get_issue"), "got: {result}");
}
#[test]
fn list_comments_empty() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "No comments");
let result = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
..Default::default()
}));
assert!(result.contains("No comments"), "got: {result}");
}
#[test]
fn add_comment_bad_identifier() {
let m = mcp();
seed_user(&m);
let result = m.add_comment(Parameters(AddCommentInput {
identifier: "NOPE-999".into(),
content: "Orphan".into(),
}));
assert!(result.contains("Error"), "got: {result}");
}
#[test]
fn add_comment_falls_back_to_first_admin() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Test issue");
let conn = m.db.write().unwrap();
queries::users::create_user(
&conn,
&models::CreateUser {
username: "admin".into(),
email: "admin@local.test".into(),
password: "adminpass123".into(),
display_name: Some("Admin User".into()),
is_admin: true,
is_bot: false,
},
)
.unwrap();
drop(conn);
*crate::mcp::MCP_REQUEST_USER
.lock()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner()) = None;
let result = m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "Comment via stdio fallback".into(),
}));
assert!(result.starts_with("Comment #"), "got: {result}");
assert!(result.contains("admin"), "got: {result}");
}
#[test]
fn add_comment_on_page_identifier_creates_page_comment() {
let m = mcp();
seed_project(&m, "Pages", "PGC");
m.create_page(Parameters(CreatePageInput {
project: Some("PGC".into()),
title: "Design".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
seed_user(&m);
let result = m.add_comment(Parameters(AddCommentInput {
identifier: "PGC-DOC-1".into(),
content: "Comment on a page".into(),
}));
assert!(result.starts_with("Comment #"), "got: {result}");
assert!(result.contains("PGC-DOC-1"), "got: {result}");
let listing = m.list_comments(Parameters(ListCommentsInput {
identifier: "PGC-DOC-1".into(),
..Default::default()
}));
assert!(listing.contains("1 comment(s)"), "got: {listing}");
assert!(listing.contains("Comment on a page"), "got: {listing}");
}
#[test]
fn page_and_issue_comments_do_not_cross_contaminate_via_mcp() {
let m = mcp();
seed_project(&m, "Mix", "MIX");
seed_issue(&m, "MIX", "An issue");
m.create_page(Parameters(CreatePageInput {
project: Some("MIX".into()),
title: "A page".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
seed_user(&m);
m.add_comment(Parameters(AddCommentInput {
identifier: "MIX-1".into(),
content: "issue thread".into(),
}));
m.add_comment(Parameters(AddCommentInput {
identifier: "MIX-DOC-1".into(),
content: "page thread".into(),
}));
let issue_listing = m.list_comments(Parameters(ListCommentsInput {
identifier: "MIX-1".into(),
..Default::default()
}));
assert!(issue_listing.contains("issue thread"), "got: {issue_listing}");
assert!(!issue_listing.contains("page thread"), "got: {issue_listing}");
let page_listing = m.list_comments(Parameters(ListCommentsInput {
identifier: "MIX-DOC-1".into(),
..Default::default()
}));
assert!(page_listing.contains("page thread"), "got: {page_listing}");
assert!(!page_listing.contains("issue thread"), "got: {page_listing}");
}
#[test]
fn add_comment_on_workspace_page() {
let m = mcp();
m.create_page(Parameters(CreatePageInput {
project: None,
title: "Workspace note".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
seed_user(&m);
let result = m.add_comment(Parameters(AddCommentInput {
identifier: "DOC-1".into(),
content: "comment on workspace page".into(),
}));
assert!(result.starts_with("Comment #"), "got: {result}");
let listing = m.list_comments(Parameters(ListCommentsInput {
identifier: "DOC-1".into(),
..Default::default()
}));
assert!(listing.contains("comment on workspace page"), "got: {listing}");
}
#[test]
fn apply_edit_replaces_single_match() {
let result = apply_edit("hello world", "world", "there", false).unwrap();
assert_eq!(result, "hello there");
}
#[test]
fn apply_edit_rejects_empty_old_string() {
let err = apply_edit("foo", "", "bar", false).unwrap_err();
assert!(matches!(err, crate::error::LificError::BadRequest(_)));
}
#[test]
fn apply_edit_rejects_identical_old_and_new() {
let err = apply_edit("foo", "foo", "foo", false).unwrap_err();
assert!(matches!(err, crate::error::LificError::BadRequest(_)));
}
#[test]
fn apply_edit_rejects_no_match() {
let err = apply_edit("hello", "missing", "x", false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not found"), "got: {msg}");
}
#[test]
fn apply_edit_rejects_multiple_matches_without_replace_all() {
let err = apply_edit("foo foo foo", "foo", "bar", false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("matches 3 locations"), "got: {msg}");
assert!(msg.contains("replace_all=true"), "got: {msg}");
}
#[test]
fn apply_edit_replace_all_substitutes_every_occurrence() {
let result = apply_edit("foo foo foo", "foo", "bar", true).unwrap();
assert_eq!(result, "bar bar bar");
}
#[test]
fn apply_edit_handles_unicode() {
let result = apply_edit("café ☕ shop", "café ☕", "tea 🍵", false).unwrap();
assert_eq!(result, "tea 🍵 shop");
}
fn seed_issue_with_description(mcp: &LificMcp, project: &str, title: &str, desc: &str) -> String {
let result = mcp.create_issue(Parameters(CreateIssueInput {
project: project.into(),
title: title.into(),
description: Some(desc.into()),
status: None,
priority: None,
module: None,
labels: None,
}));
assert!(result.starts_with("Created"), "got: {result}");
result
}
#[test]
fn edit_issue_unique_match_succeeds() {
let m = mcp();
seed_project(&m, "Test", "EDI");
seed_issue_with_description(&m, "EDI", "T", "The quick brown fox");
let result = m.edit_issue(Parameters(EditIssueInput {
identifier: "EDI-1".into(),
old_string: "brown".into(),
new_string: "red".into(),
field: None,
replace_all: None,
}));
assert!(result.starts_with("Edited"), "got: {result}");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "EDI-1".into(),
}));
assert!(detail.contains("The quick red fox"), "got: {detail}");
assert!(!detail.contains("brown"), "got: {detail}");
}
#[test]
fn edit_issue_no_match_fails_with_clear_error() {
let m = mcp();
seed_project(&m, "Test", "EDN");
seed_issue_with_description(&m, "EDN", "T", "hello world");
let result = m.edit_issue(Parameters(EditIssueInput {
identifier: "EDN-1".into(),
old_string: "missing".into(),
new_string: "x".into(),
field: None,
replace_all: None,
}));
assert!(result.starts_with("Error"), "got: {result}");
assert!(result.contains("not found"), "got: {result}");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "EDN-1".into(),
}));
assert!(detail.contains("hello world"), "got: {detail}");
}
#[test]
fn edit_issue_multiple_match_fails_without_replace_all() {
let m = mcp();
seed_project(&m, "Test", "EDM");
seed_issue_with_description(&m, "EDM", "T", "foo foo foo");
let result = m.edit_issue(Parameters(EditIssueInput {
identifier: "EDM-1".into(),
old_string: "foo".into(),
new_string: "bar".into(),
field: None,
replace_all: None,
}));
assert!(result.starts_with("Error"), "got: {result}");
assert!(result.contains("3 locations"), "got: {result}");
assert!(result.contains("replace_all"), "got: {result}");
}
#[test]
fn edit_issue_replace_all_succeeds_when_set() {
let m = mcp();
seed_project(&m, "Test", "EDA");
seed_issue_with_description(&m, "EDA", "T", "foo foo foo");
let result = m.edit_issue(Parameters(EditIssueInput {
identifier: "EDA-1".into(),
old_string: "foo".into(),
new_string: "bar".into(),
field: None,
replace_all: Some(true),
}));
assert!(result.starts_with("Edited"), "got: {result}");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "EDA-1".into(),
}));
assert!(detail.contains("bar bar bar"), "got: {detail}");
}
#[test]
fn edit_issue_empty_old_string_fails() {
let m = mcp();
seed_project(&m, "Test", "EDE");
seed_issue_with_description(&m, "EDE", "T", "anything");
let result = m.edit_issue(Parameters(EditIssueInput {
identifier: "EDE-1".into(),
old_string: "".into(),
new_string: "x".into(),
field: None,
replace_all: None,
}));
assert!(result.starts_with("Error"), "got: {result}");
assert!(result.contains("empty"), "got: {result}");
}
#[test]
fn edit_issue_identical_old_new_fails() {
let m = mcp();
seed_project(&m, "Test", "EDS");
seed_issue_with_description(&m, "EDS", "T", "hello");
let result = m.edit_issue(Parameters(EditIssueInput {
identifier: "EDS-1".into(),
old_string: "hello".into(),
new_string: "hello".into(),
field: None,
replace_all: None,
}));
assert!(result.starts_with("Error"), "got: {result}");
assert!(result.contains("differ"), "got: {result}");
}
#[test]
fn edit_issue_title_field_works() {
let m = mcp();
seed_project(&m, "Test", "EDT");
seed_issue_with_description(&m, "EDT", "Old name here", "body");
let result = m.edit_issue(Parameters(EditIssueInput {
identifier: "EDT-1".into(),
old_string: "Old".into(),
new_string: "New".into(),
field: Some("title".into()),
replace_all: None,
}));
assert!(result.starts_with("Edited"), "got: {result}");
assert!(result.contains("New name here"), "got: {result}");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "EDT-1".into(),
}));
assert!(detail.contains("body"), "got: {detail}");
}
#[test]
fn edit_issue_invalid_field_fails() {
let m = mcp();
seed_project(&m, "Test", "EDX");
seed_issue_with_description(&m, "EDX", "T", "body");
let result = m.edit_issue(Parameters(EditIssueInput {
identifier: "EDX-1".into(),
old_string: "body".into(),
new_string: "x".into(),
field: Some("status".into()),
replace_all: None,
}));
assert!(result.starts_with("Error"), "got: {result}");
assert!(result.contains("invalid field"), "got: {result}");
}
#[test]
fn edit_issue_preserves_other_fields() {
let m = mcp();
seed_project(&m, "Test", "EDP");
m.create_issue(Parameters(CreateIssueInput {
project: "EDP".into(),
title: "Stays".into(),
description: Some("change me".into()),
status: Some("active".into()),
priority: Some("high".into()),
module: None,
labels: None,
}));
m.edit_issue(Parameters(EditIssueInput {
identifier: "EDP-1".into(),
old_string: "change".into(),
new_string: "kept".into(),
field: None,
replace_all: None,
}));
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "EDP-1".into(),
}));
assert!(detail.contains("Stays"), "title preserved, got: {detail}");
assert!(detail.contains("active"), "status preserved, got: {detail}");
assert!(detail.contains("high"), "priority preserved, got: {detail}");
assert!(detail.contains("kept me"), "description edited, got: {detail}");
}
#[test]
fn edit_page_content_works() {
let m = mcp();
seed_project(&m, "Test", "EPC");
m.create_page(Parameters(CreatePageInput {
project: Some("EPC".into()),
title: "Doc".into(),
content: Some("# Heading\nold body".into()),
folder: None,
status: None,
labels: None,
}));
let result = m.edit_page(Parameters(EditPageInput {
identifier: "EPC-DOC-1".into(),
old_string: "old body".into(),
new_string: "new body".into(),
field: None,
replace_all: None,
}));
assert!(result.starts_with("Edited"), "got: {result}");
let detail = m.get_page(Parameters(GetPageInput {
identifier: "EPC-DOC-1".into(),
}));
assert!(detail.contains("new body"), "got: {detail}");
assert!(!detail.contains("old body"), "got: {detail}");
}
#[test]
fn edit_page_title_field_works() {
let m = mcp();
seed_project(&m, "Test", "EPT");
m.create_page(Parameters(CreatePageInput {
project: Some("EPT".into()),
title: "Draft Spec".into(),
content: Some("body".into()),
folder: None,
status: None,
labels: None,
}));
let result = m.edit_page(Parameters(EditPageInput {
identifier: "EPT-DOC-1".into(),
old_string: "Draft".into(),
new_string: "Final".into(),
field: Some("title".into()),
replace_all: None,
}));
assert!(result.starts_with("Edited"), "got: {result}");
assert!(result.contains("Final Spec"), "got: {result}");
}
#[test]
fn edit_page_preserves_other_fields() {
let m = mcp();
seed_project(&m, "Test", "EPP");
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "folder".into(),
action: "create".into(),
project: Some("EPP".into()),
name: Some("Specs".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
}));
m.create_page(Parameters(CreatePageInput {
project: Some("EPP".into()),
title: "Original Title".into(),
content: Some("change me".into()),
folder: Some("Specs".into()),
status: None,
labels: None,
}));
m.edit_page(Parameters(EditPageInput {
identifier: "EPP-DOC-1".into(),
old_string: "change".into(),
new_string: "kept".into(),
field: None,
replace_all: None,
}));
let detail = m.get_page(Parameters(GetPageInput {
identifier: "EPP-DOC-1".into(),
}));
assert!(detail.contains("Original Title"), "title preserved, got: {detail}");
assert!(detail.contains("kept me"), "content edited, got: {detail}");
let listing = m.list_resources(Parameters(ListResourcesInput {
resource_type: "page".into(),
project: Some("EPP".into()),
folder: Some("Specs".into()),
label: None,
limit: None,
offset: None,
..Default::default()
}));
assert!(listing.contains("EPP-DOC-1"), "folder preserved, got: {listing}");
}
#[test]
fn edit_page_no_match_fails() {
let m = mcp();
seed_project(&m, "Test", "EPN");
m.create_page(Parameters(CreatePageInput {
project: Some("EPN".into()),
title: "Doc".into(),
content: Some("hello".into()),
folder: None,
status: None,
labels: None,
}));
let result = m.edit_page(Parameters(EditPageInput {
identifier: "EPN-DOC-1".into(),
old_string: "missing".into(),
new_string: "x".into(),
field: None,
replace_all: None,
}));
assert!(result.starts_with("Error"), "got: {result}");
assert!(result.contains("not found"), "got: {result}");
}
#[test]
fn edit_page_invalid_field_fails() {
let m = mcp();
seed_project(&m, "Test", "EPX");
m.create_page(Parameters(CreatePageInput {
project: Some("EPX".into()),
title: "Doc".into(),
content: Some("body".into()),
folder: None,
status: None,
labels: None,
}));
let result = m.edit_page(Parameters(EditPageInput {
identifier: "EPX-DOC-1".into(),
old_string: "body".into(),
new_string: "x".into(),
field: Some("folder".into()),
replace_all: None,
}));
assert!(result.starts_with("Error"), "got: {result}");
assert!(result.contains("invalid field"), "got: {result}");
}
fn seed_labels_for_pages(m: &LificMcp, project_ident: &str, project_name: &str) {
seed_project(m, project_name, project_ident);
for (name, color) in [("design", "#22C55E"), ("draft", "#F59E0B")] {
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "label".into(),
action: "create".into(),
project: Some(project_ident.into()),
name: Some(name.into()),
color: Some(color.into()),
identifier: None,
description: None,
current_name: None,
status: None,
}));
}
}
#[test]
fn mcp_create_page_with_labels_returns_them_in_get() {
let m = mcp();
seed_labels_for_pages(&m, "PGL", "Pages with Labels");
let created = m.create_page(Parameters(CreatePageInput {
project: Some("PGL".into()),
title: "Spec".into(),
content: None,
folder: None,
status: None,
labels: Some(vec!["design".into()]),
}));
assert!(created.contains("PGL-DOC-1"), "got: {created}");
let detail = m.get_page(Parameters(GetPageInput {
identifier: "PGL-DOC-1".into(),
}));
assert!(detail.contains("Labels: design"), "got: {detail}");
}
#[test]
fn mcp_update_page_replaces_labels() {
let m = mcp();
seed_labels_for_pages(&m, "PUL", "Page Update Labels");
m.create_page(Parameters(CreatePageInput {
project: Some("PUL".into()),
title: "Spec".into(),
content: None,
folder: None,
status: None,
labels: Some(vec!["design".into()]),
}));
m.update_page(Parameters(UpdatePageInput {
identifier: "PUL-DOC-1".into(),
title: None,
content: None,
folder: None,
status: None,
labels: Some(vec!["draft".into()]),
}));
let detail = m.get_page(Parameters(GetPageInput {
identifier: "PUL-DOC-1".into(),
}));
assert!(detail.contains("Labels: draft"), "got: {detail}");
assert!(!detail.contains("design"), "got: {detail}");
}
#[test]
fn mcp_list_resources_pages_renders_label_brackets() {
let m = mcp();
seed_labels_for_pages(&m, "PLI", "Page List");
m.create_page(Parameters(CreatePageInput {
project: Some("PLI".into()),
title: "Tagged".into(),
content: None,
folder: None,
status: None,
labels: Some(vec!["design".into()]),
}));
m.create_page(Parameters(CreatePageInput {
project: Some("PLI".into()),
title: "Bare".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
let listing = m.list_resources(Parameters(ListResourcesInput {
resource_type: "page".into(),
project: Some("PLI".into()),
folder: None,
label: None,
limit: None,
offset: None,
..Default::default()
}));
assert!(listing.contains("Tagged [design]"), "got: {listing}");
assert!(listing.contains("| Bare — updated"), "got: {listing}");
assert!(!listing.contains("Bare []"), "got: {listing}");
}
#[test]
fn mcp_list_resources_pages_label_filter() {
let m = mcp();
seed_labels_for_pages(&m, "PLF", "Page Label Filter");
m.create_page(Parameters(CreatePageInput {
project: Some("PLF".into()),
title: "Designy".into(),
content: None,
folder: None,
status: None,
labels: Some(vec!["design".into()]),
}));
m.create_page(Parameters(CreatePageInput {
project: Some("PLF".into()),
title: "Plain".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
let filtered = m.list_resources(Parameters(ListResourcesInput {
resource_type: "page".into(),
project: Some("PLF".into()),
folder: None,
label: Some("design".into()),
limit: None,
offset: None,
..Default::default()
}));
assert!(filtered.contains("Designy"), "got: {filtered}");
assert!(!filtered.contains("Plain"), "got: {filtered}");
}
#[test]
fn mcp_workspace_page_create_with_labels_silently_drops_them() {
let m = mcp();
let created = m.create_page(Parameters(CreatePageInput {
project: None,
title: "Floating".into(),
content: None,
folder: None,
status: None,
labels: Some(vec!["anything".into()]),
}));
assert!(created.contains("DOC-1"), "got: {created}");
let detail = m.get_page(Parameters(GetPageInput {
identifier: "DOC-1".into(),
}));
assert!(!detail.contains("Labels:"), "got: {detail}");
}
#[test]
fn mcp_get_page_surfaces_status_folder_and_timestamps() {
let m = mcp();
seed_project(&m, "Meta", "MET");
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "folder".into(),
action: "create".into(),
project: Some("MET".into()),
name: Some("Specs".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
}));
m.create_page(Parameters(CreatePageInput {
project: Some("MET".into()),
title: "Spec doc".into(),
content: Some("Body text".into()),
folder: Some("Specs".into()),
status: Some("active".into()),
labels: None,
}));
let detail = m.get_page(Parameters(GetPageInput {
identifier: "MET-DOC-1".into(),
}));
assert!(detail.contains("Status: active | Folder: Specs"), "got: {detail}");
assert!(detail.contains("Created: "), "got: {detail}");
assert!(detail.contains("Updated: "), "got: {detail}");
let header_pos = detail.find("Status: active").unwrap();
let body_pos = detail.find("Body text").unwrap();
assert!(header_pos < body_pos, "metadata must precede content: {detail}");
}
#[test]
fn mcp_get_page_without_folder_says_none() {
let m = mcp();
seed_project(&m, "Meta", "MET");
m.create_page(Parameters(CreatePageInput {
project: Some("MET".into()),
title: "Loose doc".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
let detail = m.get_page(Parameters(GetPageInput {
identifier: "MET-DOC-1".into(),
}));
assert!(detail.contains("Status: draft | Folder: none"), "got: {detail}");
}
#[test]
fn mcp_list_resources_pages_filters_by_status() {
let m = mcp();
seed_project(&m, "Stat", "STA");
m.create_page(Parameters(CreatePageInput {
project: Some("STA".into()),
title: "Live".into(),
content: None,
folder: None,
status: Some("active".into()),
labels: None,
}));
m.create_page(Parameters(CreatePageInput {
project: Some("STA".into()),
title: "Old".into(),
content: None,
folder: None,
status: Some("archived".into()),
labels: None,
}));
let active = m.list_resources(Parameters(ListResourcesInput {
resource_type: "page".into(),
project: Some("STA".into()),
status: Some("active".into()),
..Default::default()
}));
assert!(active.contains("Live"), "got: {active}");
assert!(!active.contains("Old"), "got: {active}");
assert!(active.contains("| active |"), "got: {active}");
}
#[test]
fn mcp_list_resources_pages_orders_by_title_desc() {
let m = mcp();
seed_project(&m, "Ord", "ORD");
for title in ["Alpha", "Zulu", "Mike"] {
m.create_page(Parameters(CreatePageInput {
project: Some("ORD".into()),
title: title.into(),
content: None,
folder: None,
status: None,
labels: None,
}));
}
let listing = m.list_resources(Parameters(ListResourcesInput {
resource_type: "page".into(),
project: Some("ORD".into()),
order_by: Some("title".into()),
order: Some("desc".into()),
..Default::default()
}));
let zulu = listing.find("Zulu").unwrap();
let mike = listing.find("Mike").unwrap();
let alpha = listing.find("Alpha").unwrap();
assert!(zulu < mike && mike < alpha, "got: {listing}");
}
#[test]
fn mcp_list_resources_pages_shows_folder_name() {
let m = mcp();
seed_project(&m, "Fold", "FOL");
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "folder".into(),
action: "create".into(),
project: Some("FOL".into()),
name: Some("Design".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
}));
m.create_page(Parameters(CreatePageInput {
project: Some("FOL".into()),
title: "Foldered".into(),
content: None,
folder: Some("Design".into()),
status: None,
labels: None,
}));
let listing = m.list_resources(Parameters(ListResourcesInput {
resource_type: "page".into(),
project: Some("FOL".into()),
..Default::default()
}));
assert!(listing.contains("(folder: Design)"), "got: {listing}");
}
#[test]
fn mcp_list_comments_author_filter() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Authored");
seed_user(&m);
m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "Mine".into(),
}));
let mine = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
author: Some("testuser".into()),
order: None,
}));
assert!(mine.contains("Mine"), "got: {mine}");
let ghost = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
author: Some("ghost".into()),
order: None,
}));
assert!(ghost.contains("No comments"), "got: {ghost}");
}
#[test]
fn mcp_list_comments_desc_order() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Threaded");
seed_user(&m);
m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "first".into(),
}));
m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "second".into(),
}));
let listing = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
author: None,
order: Some("desc".into()),
}));
let second = listing.find("second").unwrap();
let first = listing.find("first").unwrap();
assert!(second < first, "desc must list newest first: {listing}");
let bad = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
author: None,
order: Some("newest".into()),
}));
assert!(bad.contains("Error"), "got: {bad}");
}
#[test]
fn mcp_search_result_type_filter() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "findable widget issue");
m.create_page(Parameters(CreatePageInput {
project: Some("PRJ".into()),
title: "findable widget page".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
let issues_only = m.search(Parameters(SearchInput {
query: "findable".into(),
result_type: Some("issue".into()),
..Default::default()
}));
assert!(issues_only.contains("[issue]"), "got: {issues_only}");
assert!(!issues_only.contains("[page]"), "got: {issues_only}");
let bad = m.search(Parameters(SearchInput {
query: "findable".into(),
result_type: Some("comment".into()),
..Default::default()
}));
assert!(bad.contains("Error"), "got: {bad}");
}
#[test]
fn mcp_search_pagination_emits_has_more_hint() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
for i in 0..3 {
seed_issue(&m, "PRJ", &format!("paginated result {i}"));
}
let page1 = m.search(Parameters(SearchInput {
query: "paginated".into(),
limit: Some(2),
..Default::default()
}));
assert!(page1.contains("offset=2"), "got: {page1}");
let page2 = m.search(Parameters(SearchInput {
query: "paginated".into(),
limit: Some(2),
offset: Some(2),
..Default::default()
}));
assert!(!page2.contains("more results available"), "got: {page2}");
}
#[test]
fn mcp_list_issues_date_filters() {
let m = mcp();
let ident = seed_project(&m, "Dated", "DAT");
seed_issue(&m, &ident, "Recent issue");
let none = m.list_issues(Parameters(ListIssuesInput {
project: ident.clone(),
created_since: Some("2099-01-01".into()),
..Default::default()
}));
assert!(none.contains("No issues"), "got: {none}");
let all = m.list_issues(Parameters(ListIssuesInput {
project: ident,
created_since: Some("2000-01-01".into()),
..Default::default()
}));
assert!(all.contains("Recent issue"), "got: {all}");
}
#[test]
fn mcp_list_issues_order_by_sequence_desc() {
let m = mcp();
let ident = seed_project(&m, "Sorted", "SRT");
seed_issue(&m, &ident, "Oldest");
seed_issue(&m, &ident, "Newest");
let listing = m.list_issues(Parameters(ListIssuesInput {
project: ident.clone(),
order_by: Some("sequence".into()),
order: Some("desc".into()),
..Default::default()
}));
let newest = listing.find("Newest").unwrap();
let oldest = listing.find("Oldest").unwrap();
assert!(newest < oldest, "got: {listing}");
let bad = m.list_issues(Parameters(ListIssuesInput {
project: ident,
order_by: Some("votes".into()),
..Default::default()
}));
assert!(bad.contains("Error"), "got: {bad}");
}
#[test]
fn get_activity_renders_issue_history() {
let m = mcp();
seed_project(&m, "Audit", "TST");
seed_issue(&m, "TST", "Watched issue");
let result = m.update_issue(Parameters(UpdateIssueInput {
identifier: "TST-1".into(),
status: Some("active".into()),
..Default::default()
}));
assert!(result.contains("Updated"), "got: {result}");
let out = m.get_activity(Parameters(GetActivityInput {
identifier: "TST-1".into(),
..Default::default()
}));
assert!(out.contains("status: backlog → active"), "got: {out}");
assert!(out.contains("created issue TST-1"), "got: {out}");
}
#[test]
fn get_activity_project_feed_pages_with_hint() {
let m = mcp();
seed_project(&m, "Audit", "TST");
for i in 0..4 {
seed_issue(&m, "TST", &format!("issue {i}"));
}
let out = m.get_activity(Parameters(GetActivityInput {
identifier: "TST".into(),
limit: Some(2),
..Default::default()
}));
assert!(out.contains("2 activity entries"), "got: {out}");
assert!(out.contains("offset=2"), "paging hint expected: {out}");
let next = m.get_activity(Parameters(GetActivityInput {
identifier: "TST".into(),
limit: Some(50),
offset: Some(2),
}));
assert!(next.contains("3 activity entries"), "got: {next}");
assert!(!next.contains("offset="), "no further hint: {next}");
}
#[test]
fn get_activity_rejects_unknown_identifier() {
let m = mcp();
seed_project(&m, "Audit", "TST");
let out = m.get_activity(Parameters(GetActivityInput {
identifier: "NOPE-999".into(),
..Default::default()
}));
assert!(out.starts_with("Error"), "got: {out}");
}
#[tokio::test]
async fn get_activity_attributes_mcp_actor() {
let m = mcp();
seed_project(&m, "Audit", "TST");
let bot_id = {
let conn = m.db.write().unwrap();
conn.execute(
"INSERT INTO users (username, email, password_hash, display_name, is_admin, is_bot)
VALUES ('opencode-blake', 'oc@test.local', 'x', 'opencode-blake', 0, 1)",
[],
)
.unwrap();
conn.last_insert_rowid()
};
let user = crate::db::models::AuthUser {
id: bot_id,
username: "opencode-blake".into(),
display_name: "opencode-blake".into(),
is_admin: false,
};
crate::mcp::with_request_user(Some(user), || async {
seed_issue(&m, "TST", "Agent-made");
})
.await;
let out = m.get_activity(Parameters(GetActivityInput {
identifier: "TST-1".into(),
..Default::default()
}));
assert!(
out.contains("opencode-blake (agent) via mcp"),
"got: {out}"
);
}
#[tokio::test]
async fn mcp_attribution_survives_task_spawn() {
let m = mcp();
seed_project(&m, "Audit", "TST");
let bot_id = {
let conn = m.db.write().unwrap();
conn.execute(
"INSERT INTO users (username, email, password_hash, display_name, is_admin, is_bot)
VALUES ('opencode-blake', 'oc@test.local', 'x', 'opencode-blake', 0, 1)",
[],
)
.unwrap();
conn.last_insert_rowid()
};
let user = crate::db::models::AuthUser {
id: bot_id,
username: "opencode-blake".into(),
display_name: "opencode-blake".into(),
is_admin: false,
};
crate::mcp::with_request_user(Some(user), || async {
let m2 = m.clone();
tokio::spawn(async move {
let result = m2.create_issue(Parameters(CreateIssueInput {
project: "TST".into(),
title: "Spawned write".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
}));
assert!(result.starts_with("Created"), "got: {result}");
})
.await
.unwrap();
})
.await;
let out = m.get_activity(Parameters(GetActivityInput {
identifier: "TST-1".into(),
..Default::default()
}));
assert!(
out.contains("opencode-blake (agent) via mcp"),
"spawned tool write must still attribute: {out}"
);
}
}