use std::{
cmp::Ordering,
fmt::{self, Display, Write as _},
sync::Arc,
};
use chrono::{DateTime, NaiveDateTime, Utc};
use rmcp::{handler::server::wrapper::Parameters, tool, tool_router};
use crate::{
db::{DbPool, models, queries},
links::{IssueLinkContext, MarkdownReference},
};
use super::schemas::*;
use super::{LificMcp, current_issue_link_context};
pub(crate) const NO_PROJECTS_NUDGE: &str = "No projects exist yet. Create one first: manage_resource(resource_type='project', action='create', name='My Project', identifier='PRO'). Then create issues with create_issue(project='PRO', ...).";
impl LificMcp {
pub(crate) fn create_tool_router() -> rmcp::handler::server::router::tool::ToolRouter<Self> {
Self::tool_router()
}
}
fn try_render(render: impl FnOnce(&mut String) -> fmt::Result) -> Result<String, fmt::Error> {
let mut output = String::new();
render(&mut output).map(|()| output)
}
fn render_response(render: impl FnOnce(&mut String) -> fmt::Result) -> String {
match try_render(render) {
Ok(output) => output,
Err(error) => format!("Error: failed to format response: {error}"),
}
}
fn finish_response(output: String, result: fmt::Result) -> String {
match result {
Ok(()) => output,
Err(error) => format!("Error: failed to format response: {error}"),
}
}
fn write_joined<T>(
formatter: &mut fmt::Formatter<'_>,
values: &[T],
separator: &str,
mut write_value: impl FnMut(&mut fmt::Formatter<'_>, &T) -> fmt::Result,
) -> fmt::Result {
values.split_first().map_or(Ok(()), |(first, rest)| {
write_value(formatter, first)?;
rest.iter().try_for_each(|value| {
formatter.write_str(separator)?;
write_value(formatter, value)
})
})
}
struct JoinedStrings<'a> {
values: &'a [String],
separator: &'a str,
}
impl Display for JoinedStrings<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write_joined(
formatter,
self.values,
self.separator,
|formatter, value| formatter.write_str(value),
)
}
}
struct IssueReference<'a> {
context: Option<&'a IssueLinkContext>,
identifier: &'a str,
}
impl Display for IssueReference<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.context {
Some(context) => Display::fmt(&context.issue_markdown(self.identifier), formatter),
None => formatter.write_str(self.identifier),
}
}
}
struct IssueRelations<'a> {
label: &'a str,
identifiers: &'a [String],
context: Option<&'a IssueLinkContext>,
}
impl Display for IssueRelations<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.identifiers.split_first().map_or(Ok(()), |_| {
formatter.write_str(self.label)?;
write_joined(formatter, self.identifiers, ",", |formatter, identifier| {
Display::fmt(
&IssueReference {
context: self.context,
identifier,
},
formatter,
)
})
})
}
}
struct IssueLine<'a> {
issue: &'a models::Issue,
context: Option<&'a IssueLinkContext>,
}
impl Display for IssueLine<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let issue = self.issue;
write!(
formatter,
"{} | {} | {} | {}",
IssueReference {
context: self.context,
identifier: &issue.identifier,
},
issue.status,
issue.priority,
issue.title
)?;
issue.labels.split_first().map_or(Ok(()), |_| {
write!(
formatter,
" [{}]",
JoinedStrings {
values: &issue.labels,
separator: ", ",
}
)
})?;
[
(" blocks:", issue.blocks.as_slice()),
(" blocked_by:", issue.blocked_by.as_slice()),
(" duplicates:", issue.duplicates.as_slice()),
(" duplicated_by:", issue.duplicated_by.as_slice()),
]
.into_iter()
.try_for_each(|(label, identifiers)| {
Display::fmt(
&IssueRelations {
label,
identifiers,
context: self.context,
},
formatter,
)
})
}
}
#[derive(Clone, Copy)]
enum ReferenceKind<'a> {
Issue(&'a str),
Project(&'a str),
Page(&'a str, i64),
Plan(&'a str, i64),
Module(&'a str, i64, &'a str),
IssueComment(&'a str, i64),
PageComment(&'a str, i64, i64),
IssueSearchComment(&'a str, i64),
PageSearchComment(&'a str, i64, i64),
}
#[derive(Clone, Copy)]
enum ActivityScopeReference {
Issue,
Page(i64),
Project,
}
struct Reference<'a> {
context: Option<&'a IssueLinkContext>,
kind: ReferenceKind<'a>,
}
impl Display for Reference<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.kind.fmt_with_context(self.context, formatter)
}
}
impl ReferenceKind<'_> {
fn fmt_with_context(
self,
context: Option<&IssueLinkContext>,
formatter: &mut fmt::Formatter<'_>,
) -> fmt::Result {
match context {
Some(context) => self.fmt_linked(context, formatter),
None => self.fmt_plain(formatter),
}
}
fn fmt_linked(
self,
context: &IssueLinkContext,
formatter: &mut fmt::Formatter<'_>,
) -> fmt::Result {
match self {
Self::Issue(identifier) => Display::fmt(&context.issue_markdown(identifier), formatter),
Self::Project(identifier) => {
Display::fmt(&context.project_markdown(identifier), formatter)
}
Self::Page(identifier, page_id) => {
Display::fmt(&context.page_markdown(identifier, page_id), formatter)
}
Self::Plan(identifier, plan_id) => {
Display::fmt(&context.plan_markdown(identifier, plan_id), formatter)
}
Self::Module(project, module_id, label) => Display::fmt(
&context.module_markdown(project, module_id, label),
formatter,
),
Self::IssueComment(identifier, comment_id)
| Self::IssueSearchComment(identifier, comment_id) => Display::fmt(
&context.issue_comment_markdown(identifier, comment_id),
formatter,
),
Self::PageComment(identifier, page_id, comment_id)
| Self::PageSearchComment(identifier, page_id, comment_id) => Display::fmt(
&context.page_comment_markdown(identifier, page_id, comment_id),
formatter,
),
}
}
fn fmt_plain(self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Module(_, _, label) => formatter.write_str(label),
Self::IssueSearchComment(_, _) | Self::PageSearchComment(_, _, _) => {
formatter.write_str("[comment]")
}
Self::IssueComment(_, comment_id) | Self::PageComment(_, _, comment_id) => {
write!(formatter, "comment #{comment_id}")
}
Self::Issue(identifier)
| Self::Project(identifier)
| Self::Page(identifier, _)
| Self::Plan(identifier, _) => formatter.write_str(identifier),
}
}
}
fn reference_with_context<'a>(
context: Option<&'a IssueLinkContext>,
kind: ReferenceKind<'a>,
) -> Reference<'a> {
Reference { context, kind }
}
fn comment_reference_kind(
parent_identifier: &str,
parent: queries::comments::CommentParent,
comment_id: i64,
) -> ReferenceKind<'_> {
match parent {
queries::comments::CommentParent::Issue(_) => {
ReferenceKind::IssueComment(parent_identifier, comment_id)
}
queries::comments::CommentParent::Page(page_id) => {
ReferenceKind::PageComment(parent_identifier, page_id, comment_id)
}
}
}
fn comment_parent_reference_kind(
parent_identifier: &str,
parent: queries::comments::CommentParent,
) -> ReferenceKind<'_> {
match parent {
queries::comments::CommentParent::Issue(_) => ReferenceKind::Issue(parent_identifier),
queries::comments::CommentParent::Page(page_id) => {
ReferenceKind::Page(parent_identifier, page_id)
}
}
}
fn issue_reference<'a>(
context: Option<&'a IssueLinkContext>,
identifier: &'a str,
) -> Reference<'a> {
reference_with_context(context, ReferenceKind::Issue(identifier))
}
fn project_reference<'a>(
context: Option<&'a IssueLinkContext>,
identifier: &'a str,
) -> Reference<'a> {
reference_with_context(context, ReferenceKind::Project(identifier))
}
fn page_reference<'a>(
context: Option<&'a IssueLinkContext>,
page: &'a models::Page,
) -> Reference<'a> {
reference_with_context(context, ReferenceKind::Page(&page.identifier, page.id))
}
fn plan_reference<'a>(
context: Option<&'a IssueLinkContext>,
plan: &'a models::Plan,
) -> Reference<'a> {
plan_reference_value(context, &plan.identifier, plan.id)
}
fn plan_reference_value<'a>(
context: Option<&'a IssueLinkContext>,
identifier: &'a str,
plan_id: i64,
) -> Reference<'a> {
reference_with_context(context, ReferenceKind::Plan(identifier, plan_id))
}
fn module_reference<'a>(
context: Option<&'a IssueLinkContext>,
project: &'a str,
module: &'a models::Module,
) -> Reference<'a> {
reference_with_context(
context,
ReferenceKind::Module(project, module.id, &module.name),
)
}
struct IssueReferenceWithSuffix<'a> {
context: Option<&'a IssueLinkContext>,
value: &'a str,
}
impl Display for IssueReferenceWithSuffix<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match (self.context, self.value.split_once(' ')) {
(Some(context), Some((identifier, suffix))) => match context.issue_url(identifier) {
Some(_) => write!(formatter, "{} {suffix}", context.issue_markdown(identifier)),
None => formatter.write_str(self.value),
},
(Some(context), None) => Display::fmt(&context.issue_markdown(self.value), formatter),
(None, _) => formatter.write_str(self.value),
}
}
}
struct IssueReferenceList<'a> {
label: &'a str,
values: &'a [String],
context: Option<&'a IssueLinkContext>,
}
impl Display for IssueReferenceList<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.values.split_first().map_or(Ok(()), |_| {
formatter.write_str(self.label)?;
write_joined(formatter, self.values, ", ", |formatter, value| {
Display::fmt(
&IssueReferenceWithSuffix {
context: self.context,
value,
},
formatter,
)
})?;
formatter.write_char('\n')
})
}
}
struct CommentLines<'a> {
comments: &'a [models::Comment],
parent_identifier: &'a str,
parent: queries::comments::CommentParent,
context: Option<&'a IssueLinkContext>,
}
impl Display for CommentLines<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.comments.iter().try_for_each(|comment| {
write!(
formatter,
"[{}] {} ({})",
comment.created_at, comment.author, comment.author_display_name
)?;
self.context.map_or(Ok(()), |context| {
write!(
formatter,
" — {}",
reference_with_context(
Some(context),
comment_reference_kind(self.parent_identifier, self.parent, comment.id),
)
)
})?;
writeln!(formatter, ": {}", comment.content)
})
}
}
pub(crate) fn fmt_plan(p: &models::Plan) -> String {
let context = current_issue_link_context();
render_response(|output| {
write!(
output,
"{}",
PlanView {
plan: p,
context: context.as_deref(),
}
)
})
}
struct PlanView<'a> {
plan: &'a models::Plan,
context: Option<&'a IssueLinkContext>,
}
impl Display for PlanView<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let plan = self.plan;
write!(
formatter,
"{} [{}] {}",
plan_reference(self.context, plan),
plan.status,
plan.title
)?;
plan.anchor_identifier.as_deref().map_or(Ok(()), |anchor| {
write!(
formatter,
" — anchor {}",
issue_reference(self.context, anchor)
)
})?;
writeln!(formatter, " — {}/{} done", plan.done_count, plan.step_count)?;
Display::fmt(
&PlanSteps {
nodes: &plan.steps,
depth: 0,
context: self.context,
},
formatter,
)
}
}
#[derive(Clone, Copy)]
enum RelativeAge {
Minutes(i64),
Hours(i64),
Days(i64),
}
impl Display for RelativeAge {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Minutes(value) => write!(formatter, "{value}m ago"),
Self::Hours(value) => write!(formatter, "{value}h ago"),
Self::Days(value) => write!(formatter, "{value}d ago"),
}
}
}
fn relative_age(timestamp: &str, now: DateTime<Utc>) -> Option<RelativeAge> {
let activity_at = match NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S") {
Ok(timestamp) => timestamp.and_utc(),
Err(_) => DateTime::parse_from_rfc3339(timestamp)
.ok()?
.with_timezone(&Utc),
};
let minutes = now.signed_duration_since(activity_at).num_minutes().max(0);
Some(if minutes < 60 {
RelativeAge::Minutes(minutes)
} else if minutes < 24 * 60 {
RelativeAge::Hours(minutes / 60)
} else {
RelativeAge::Days(minutes / (24 * 60))
})
}
#[derive(Clone, Copy)]
enum ProjectAgentStat {
Workable(i64),
ActivePlans(i64),
LastActivity(RelativeAge),
}
impl Display for ProjectAgentStat {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Workable(count) => write!(formatter, "{count} workable"),
Self::ActivePlans(1) => formatter.write_str("1 active plan"),
Self::ActivePlans(count) => write!(formatter, "{count} active plans"),
Self::LastActivity(age) => write!(formatter, "last activity {age}"),
}
}
}
struct ProjectAgentStats<'a> {
stats: &'a queries::ProjectAgentStats,
now: DateTime<Utc>,
}
impl Display for ProjectAgentStats<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let stats = self.stats;
let mut parts = [
(stats.workable > 0).then_some(ProjectAgentStat::Workable(stats.workable)),
(stats.active_plans > 0).then_some(ProjectAgentStat::ActivePlans(stats.active_plans)),
stats
.last_activity
.as_deref()
.and_then(|timestamp| relative_age(timestamp, self.now))
.map(ProjectAgentStat::LastActivity),
]
.into_iter()
.flatten();
let Some(first) = parts.next() else {
return Ok(());
};
write!(formatter, " ({first}")?;
parts.try_for_each(|part| write!(formatter, ", {part}"))?;
formatter.write_char(')')
}
}
fn cmp_projects_by_activity(
left: &models::Project,
right: &models::Project,
stats: &std::collections::HashMap<i64, queries::ProjectAgentStats>,
) -> Ordering {
let left_activity = stats
.get(&left.id)
.and_then(|stats| stats.last_activity.as_deref());
let right_activity = stats
.get(&right.id)
.and_then(|stats| stats.last_activity.as_deref());
match (left_activity, right_activity) {
(Some(left_activity), Some(right_activity)) => right_activity.cmp(left_activity),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
}
.then_with(|| left.sort_order.cmp(&right.sort_order))
.then_with(|| left.name.cmp(&right.name))
}
#[derive(Clone, Copy)]
enum PlanStepCascadeAction {
AutoComplete,
Reopen,
}
impl PlanStepCascadeAction {
fn audit_action(self) -> &'static str {
match self {
Self::AutoComplete => "auto-complete",
Self::Reopen => "auto-reopen",
}
}
fn response_verb(self) -> &'static str {
match self {
Self::AutoComplete => "auto-completed",
Self::Reopen => "reopened",
}
}
}
struct CascadedPlanStep {
id: i64,
plan_id: i64,
plan_identifier: String,
}
fn issue_status_cascaded_plan_steps(
conn: &rusqlite::Connection,
audit_checkpoint: i64,
issue_id: i64,
action: PlanStepCascadeAction,
) -> Result<Vec<CascadedPlanStep>, crate::error::LificError> {
let mut stmt = conn.prepare_cached(
"SELECT ps.id, pl.id, p.identifier || '-PLAN-' || pl.sequence
FROM audit_log al
JOIN plan_steps ps ON ps.id = al.entity_id
JOIN plans pl ON pl.id = ps.plan_id
JOIN projects p ON p.id = pl.project_id
WHERE al.id > ?1
AND al.issue_id = ?2
AND al.entity_type = 'plan_step'
AND al.action = ?3
ORDER BY al.id ASC",
)?;
let rows = stmt.query_map(
rusqlite::params![audit_checkpoint, issue_id, action.audit_action()],
|row| {
Ok(CascadedPlanStep {
id: row.get(0)?,
plan_id: row.get(1)?,
plan_identifier: row.get(2)?,
})
},
)?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
}
struct IssuePlanStepCascade<'a> {
action: PlanStepCascadeAction,
steps: &'a [CascadedPlanStep],
context: Option<&'a IssueLinkContext>,
}
impl Display for IssuePlanStepCascade<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.steps {
[] => Ok(()),
[step] => write!(
formatter,
" ({} plan step #{} in {})",
self.action.response_verb(),
step.id,
plan_reference_value(self.context, &step.plan_identifier, step.plan_id)
),
steps => {
write!(
formatter,
" ({} {} plan steps: ",
self.action.response_verb(),
steps.len()
)?;
write_joined(formatter, steps, ", ", |formatter, step| {
write!(
formatter,
"#{} in {}",
step.id,
plan_reference_value(self.context, &step.plan_identifier, step.plan_id)
)
})?;
formatter.write_char(')')
}
}
}
}
struct PlanSteps<'a> {
nodes: &'a [models::PlanStepNode],
depth: usize,
context: Option<&'a IssueLinkContext>,
}
impl Display for PlanSteps<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.nodes.iter().try_for_each(|node| {
let check = if node.done { "x" } else { " " };
(0..self.depth).try_for_each(|_| formatter.write_str(" "))?;
write!(formatter, "- [{check}] #{} {}", node.id, node.title)?;
match (&node.issue_identifier, node.issue_status.as_deref()) {
(Some(iss), Some("done")) if node.done => {
write!(formatter, " (via {})", issue_reference(self.context, iss))
}
(Some(iss), _) if node.reopened_via_issue_at.is_some() && !node.done => {
write!(
formatter,
" (reopened — {} reopened)",
issue_reference(self.context, iss)
)
}
(Some(iss), Some(st)) => {
write!(formatter, " [{}: {st}]", issue_reference(self.context, iss))
}
(Some(iss), None) => {
write!(formatter, " [{}]", issue_reference(self.context, iss))
}
_ => Ok(()),
}?;
formatter.write_char('\n')?;
(!node.description.is_empty())
.then(|| {
(0..self.depth).try_for_each(|_| formatter.write_str(" "))?;
writeln!(formatter, " {}", truncate_value(&node.description, 100))
})
.transpose()?;
(!node.children.is_empty())
.then(|| {
Display::fmt(
&Self {
nodes: &node.children,
depth: self.depth + 1,
context: self.context,
},
formatter,
)
})
.transpose()?;
Ok(())
})
}
}
struct AnnotatedRelations {
blocks: Vec<String>,
blocked_by: Vec<String>,
relates_to: Vec<String>,
duplicates: Vec<String>,
duplicated_by: Vec<String>,
}
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 canonical_project_identifier(db: &Arc<DbPool>, project_id: i64) -> Result<String, String> {
let conn = db.read().map_err(|e| e.to_string())?;
queries::get_project(&conn, project_id)
.map(|project| project.identifier)
.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 emoji_for_create(emoji: &Option<String>) -> Option<String> {
emoji.as_ref().filter(|s| !s.is_empty()).cloned()
}
fn emoji_for_update(emoji: &Option<String>) -> Option<Option<String>> {
match emoji {
None => None,
Some(s) if s.is_empty() => Some(None),
Some(s) => Some(Some(s.clone())),
}
}
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, Option<i64>), 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())?;
let page = queries::get_page(&conn, id).map_err(|e| e.to_string())?;
Ok((
queries::comments::CommentParent::Page(page.id),
page.identifier,
page.project_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())?;
let issue = queries::get_issue(&conn, id).map_err(|e| e.to_string())?;
Ok((
queries::comments::CommentParent::Issue(issue.id),
issue.identifier,
Some(issue.project_id),
))
}
}
fn resolve_comment_context(
conn: &rusqlite::Connection,
comment: &models::Comment,
) -> Result<(Option<i64>, queries::comments::CommentParent, String), crate::error::LificError> {
if let Some(issue_id) = comment.issue_id {
let issue = queries::get_issue(conn, issue_id)?;
Ok((
Some(issue.project_id),
queries::comments::CommentParent::Issue(issue.id),
issue.identifier,
))
} else if let Some(page_id) = comment.page_id {
let page = queries::get_page(conn, page_id)?;
Ok((
page.project_id,
queries::comments::CommentParent::Page(page.id),
page.identifier,
))
} else {
Err(crate::error::LificError::Internal(format!(
"comment {} has no parent",
comment.id
)))
}
}
fn append_pagination_hint(
output: &mut impl fmt::Write,
has_more: bool,
next_offset: i64,
) -> fmt::Result {
has_more
.then(|| {
writeln!(
output,
"\n... more results available — call again with offset={next_offset}"
)
})
.transpose()
.map(|_| ())
}
struct TruncatedValue<'a> {
value: &'a str,
max: usize,
}
impl Display for TruncatedValue<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut characters = self.value.chars();
characters
.by_ref()
.take(self.max)
.try_for_each(|character| {
formatter.write_char(if character == '\n' { ' ' } else { character })
})?;
characters
.next()
.map_or(Ok(()), |_| formatter.write_char('…'))
}
}
fn truncate_value(value: &str, max: usize) -> TruncatedValue<'_> {
TruncatedValue { value, max }
}
fn resolves_to(result: Result<i64, crate::error::LificError>, expected: i64) -> bool {
result.is_ok_and(|id| id == expected)
}
fn activity_comment_reference<'a>(
conn: &rusqlite::Connection,
context: &'a IssueLinkContext,
activity: &models::Activity,
label: &'a str,
) -> MarkdownReference<'a> {
let (issue_id, page_id) = if activity.action == "delete" {
(activity.issue_id, activity.page_id)
} else {
let Ok(comment) = queries::comments::get_comment(conn, activity.entity_id) else {
return MarkdownReference::plain(label);
};
if comment.issue_id != activity.issue_id || comment.page_id != activity.page_id {
return MarkdownReference::plain(label);
}
(comment.issue_id, comment.page_id)
};
match (issue_id, page_id) {
(Some(issue_id), _) if resolves_to(queries::resolve_identifier(conn, label), issue_id) => {
if activity.action == "delete" {
context.issue_markdown(label)
} else {
context.issue_comment_markdown(label, activity.entity_id)
}
}
(_, Some(page_id))
if resolves_to(queries::resolve_page_identifier(conn, label), page_id) =>
{
if activity.action == "delete" {
context.page_markdown(label, page_id)
} else {
context.page_comment_markdown(label, page_id, activity.entity_id)
}
}
_ => MarkdownReference::plain(label),
}
}
fn activity_reference<'a>(
conn: &rusqlite::Connection,
activity: &'a models::Activity,
project: Option<&'a str>,
context: Option<&'a IssueLinkContext>,
) -> MarkdownReference<'a> {
let label = activity.entity_label.as_deref().unwrap_or("?");
let Some(context) = context else {
return MarkdownReference::plain(label);
};
if activity.entity_type == "plan_step" {
return queries::plans::resolve_plan_identifier(conn, label).map_or_else(
|_| MarkdownReference::plain(label),
|plan_id| context.plan_markdown(label, plan_id),
);
}
if activity.entity_type == "comment" {
return activity_comment_reference(conn, context, activity, label);
}
if activity.action == "delete" {
return MarkdownReference::plain(label);
}
match activity.entity_type.as_str() {
"issue" if resolves_to(queries::resolve_identifier(conn, label), activity.entity_id) => {
context.issue_markdown(label)
}
"project"
if resolves_to(
queries::resolve_project_identifier(conn, label),
activity.entity_id,
) =>
{
context.project_markdown(label)
}
"page"
if resolves_to(
queries::resolve_page_identifier(conn, label),
activity.entity_id,
) =>
{
context.page_markdown(label, activity.entity_id)
}
"plan"
if resolves_to(
queries::plans::resolve_plan_identifier(conn, label),
activity.entity_id,
) =>
{
context.plan_markdown(label, activity.entity_id)
}
"module" => match (project, queries::get_module(conn, activity.entity_id).ok()) {
(Some(project), Some(module))
if Some(module.project_id) == activity.project_id
&& resolves_to(
queries::resolve_project_identifier(conn, project),
module.project_id,
) =>
{
context.module_markdown(project, activity.entity_id, label)
}
_ => MarkdownReference::plain(label),
},
_ => MarkdownReference::plain(label),
}
}
fn activity_issue_reference<'a>(
conn: &rusqlite::Connection,
context: Option<&'a IssueLinkContext>,
identifier: &'a str,
) -> MarkdownReference<'a> {
let Some(context) = context else {
return MarkdownReference::plain(identifier);
};
queries::resolve_identifier(conn, identifier).map_or_else(
|_| MarkdownReference::plain(identifier),
|id| {
queries::get_issue(conn, id).map_or_else(
|_| MarkdownReference::plain(identifier),
|issue| {
if issue.identifier == identifier {
context.issue_markdown(identifier)
} else {
MarkdownReference::plain(identifier)
}
},
)
},
)
}
fn activity_field_value<'a>(
conn: &rusqlite::Connection,
context: Option<&'a IssueLinkContext>,
field: Option<&str>,
value: &'a str,
) -> ActivityFieldValue<'a> {
if matches!(field, Some("issue" | "anchor_issue")) {
ActivityFieldValue::Reference(activity_issue_reference(conn, context, value))
} else {
ActivityFieldValue::Text(truncate_value(value, 60))
}
}
enum ActivityFieldValue<'a> {
Reference(MarkdownReference<'a>),
Text(TruncatedValue<'a>),
}
impl Display for ActivityFieldValue<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Reference(reference) => Display::fmt(reference, formatter),
Self::Text(value) => Display::fmt(value, formatter),
}
}
}
struct ActivityLine<'a> {
conn: &'a rusqlite::Connection,
activity: &'a models::Activity,
project: Option<&'a str>,
context: Option<&'a IssueLinkContext>,
}
impl Display for ActivityLine<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let activity = self.activity;
let who = match (&activity.actor_display_name, &activity.actor_username) {
(Some(display_name), _) if !display_name.is_empty() => display_name.as_str(),
(_, Some(username)) => username.as_str(),
_ => "system",
};
let agent = if activity.actor_is_bot {
" (agent)"
} else {
""
};
let label = activity_reference(self.conn, activity, self.project, self.context);
write!(
formatter,
"[{}] {}{} via {} — ",
activity.ts, who, agent, activity.transport
)?;
match activity.action.as_str() {
"create" => write!(
formatter,
"created {} {}: {}",
activity.entity_type,
label,
truncate_value(activity.new_value.as_deref().unwrap_or(""), 60)
),
"delete" => write!(
formatter,
"deleted {} {} ({})",
activity.entity_type,
label,
truncate_value(activity.old_value.as_deref().unwrap_or(""), 60)
),
"update" => write!(
formatter,
"{} {}: {} → {}",
label,
activity.field.as_deref().unwrap_or("?"),
activity_field_value(
self.conn,
self.context,
activity.field.as_deref(),
activity.old_value.as_deref().unwrap_or("(none)")
),
activity_field_value(
self.conn,
self.context,
activity.field.as_deref(),
activity.new_value.as_deref().unwrap_or("(none)")
)
),
"attach" => write!(
formatter,
"{} +label {}",
label,
activity.new_value.as_deref().unwrap_or("?")
),
"detach" => write!(
formatter,
"{} -label {}",
label,
activity.old_value.as_deref().unwrap_or("?")
),
"link" => write!(
formatter,
"{} {} → {}",
label,
activity.field.as_deref().unwrap_or("relates_to"),
activity_issue_reference(
self.conn,
self.context,
activity.new_value.as_deref().unwrap_or("?")
)
),
"unlink" => write!(
formatter,
"{} un-{} → {}",
label,
activity.field.as_deref().unwrap_or("relates_to"),
activity_issue_reference(
self.conn,
self.context,
activity.old_value.as_deref().unwrap_or("?")
)
),
other => write!(formatter, "{label} {other}"),
}
}
}
fn mcp_gate(
db: &Arc<DbPool>,
check: impl FnOnce() -> Result<(), crate::error::LificError>,
) -> Result<(), String> {
match crate::authz::authz_enforced(db) {
Ok(false) => Ok(()),
Ok(true) => check().map_err(|e| e.to_string()),
Err(e) => Err(e.to_string()),
}
}
fn require_role_mcp(db: &Arc<DbPool>, project_id: i64, min: models::Role) -> Result<(), String> {
mcp_gate(db, || {
crate::authz::require_role(db, &super::current_auth_user(), project_id, min)
})
}
fn require_structure_role_mcp(db: &Arc<DbPool>, project_id: i64) -> Result<(), String> {
mcp_gate(db, || {
crate::authz::require_structure_role(db, &super::current_auth_user(), project_id)
})
}
fn require_project_delete_role_mcp(db: &Arc<DbPool>, project_id: i64) -> Result<(), String> {
mcp_gate(db, || {
crate::authz::require_project_delete_role(db, &super::current_auth_user(), project_id)
})
}
fn require_workspace_admin_mcp(db: &Arc<DbPool>) -> Result<(), String> {
mcp_gate(db, || {
crate::authz::require_workspace_admin(db, &super::current_auth_user())
})
}
fn require_page_role_mcp(
db: &Arc<DbPool>,
project_id: Option<i64>,
min: models::Role,
) -> Result<(), String> {
match project_id {
Some(pid) => require_role_mcp(db, pid, min),
None => require_workspace_admin_mcp(db),
}
}
fn filter_visible<T>(
items: Vec<T>,
visible: &Option<std::collections::HashSet<i64>>,
project_id_of: impl Fn(&T) -> Option<i64>,
) -> Vec<T> {
match visible {
None => items,
Some(ids) => items
.into_iter()
.filter(|it| project_id_of(it).is_some_and(|pid| ids.contains(&pid)))
.collect(),
}
}
fn visible_project_ids_mcp(
db: &Arc<DbPool>,
) -> Result<Option<std::collections::HashSet<i64>>, String> {
crate::authz::visible_project_ids(db, &super::current_auth_user()).map_err(|e| e.to_string())
}
impl LificMcp {
fn no_projects_nudge(&self) -> Option<String> {
match self.read(queries::list_projects) {
Ok(ps) if ps.is_empty() => Some(NO_PROJECTS_NUDGE.to_string()),
_ => None,
}
}
fn require_comment_role_mcp(
&self,
parent: queries::comments::CommentParent,
min: models::Role,
) -> Result<(), String> {
let project_id: Option<i64> = match parent {
queries::comments::CommentParent::Issue(id) => {
Some(self.read(|conn| queries::get_issue(conn, id))?.project_id)
}
queries::comments::CommentParent::Page(id) => {
self.read(|conn| queries::get_page(conn, id))?.project_id
}
};
require_page_role_mcp(&self.db, project_id, min)
}
fn resolve_comment_actor(&self) -> Result<(i64, bool), String> {
match super::current_auth_user() {
Some(u) => Ok((u.id, u.is_admin)),
None => match self.read(queries::users::first_admin)? {
Some(admin) => Ok((admin.id, admin.is_admin)),
None => Err("no admin user exists to attribute comment edits to.".into()),
},
}
}
fn require_step_issue_role_mcp(&self, step_id: i64, min: models::Role) -> Result<(), String> {
match self.read(|conn| queries::plans::step_issue_id(conn, step_id))? {
Some(issue_id) => {
let project_id = self
.read(|conn| queries::get_issue(conn, issue_id))?
.project_id;
require_role_mcp(&self.db, project_id, min)
}
None => Ok(()),
}
}
fn require_issue_ident_role_mcp(&self, ident: &str, min: models::Role) -> Result<(), String> {
let project_id = self.read(|conn| {
let iid = queries::resolve_identifier(conn, ident)?;
Ok(queries::get_issue(conn, iid)?.project_id)
})?;
require_role_mcp(&self.db, project_id, min)
}
}
#[tool_router]
impl LificMcp {
#[tool(description = "Search across all issues, pages, and comments 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);
let visible = match visible_project_ids_mcp(&self.db) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
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(),
mode: input.mode.clone(),
limit: Some(limit + 1),
offset: Some(offset),
},
)
}) {
Ok(results) => {
let mut results = filter_visible(results, &visible, |r| r.project_id);
if results.is_empty() {
if let Some(nudge) = self.no_projects_nudge() {
return nudge;
}
return "No results found.".into();
}
let has_more = results.len() as i64 > limit;
if has_more {
results.truncate(limit as usize);
}
let link_context = current_issue_link_context();
render_response(|output| {
writeln!(output, "{} results:", results.len())?;
results.iter().try_for_each(|result| {
let identifier = result.identifier.as_deref().unwrap_or("");
if result.result_type == "comment" {
let page_id = result.parent_page_id;
let parent = reference_with_context(
link_context.as_deref(),
page_id.map_or(ReferenceKind::Issue(identifier), |page_id| {
ReferenceKind::Page(identifier, page_id)
}),
);
let comment = reference_with_context(
link_context.as_deref(),
page_id.map_or(
ReferenceKind::IssueSearchComment(identifier, result.id),
|page_id| {
ReferenceKind::PageSearchComment(
identifier, page_id, result.id,
)
},
),
);
writeln!(output, "- {comment} on {parent} — {}", result.snippet)
} else {
let kind = match result.result_type.as_str() {
"page" => ReferenceKind::Page(identifier, result.id),
"plan" => ReferenceKind::Plan(identifier, result.id),
_ => ReferenceKind::Issue(identifier),
};
writeln!(
output,
"- [{}] {} {} — {}",
result.result_type,
reference_with_context(link_context.as_deref(), kind),
result.title,
result.snippet
)
}
})?;
append_pagination_hint(output, has_more, offset + limit)
})
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Read the audit log: who changed what, when, and through which door (web UI, MCP, API, CLI). Takes an issue, page, or project ID; project scope covers the whole feed. Newest-first with old and new values."
)]
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, project_id, scope_reference, scope_identifier, project_identifier) =
if looks_like_page_identifier(ident) {
match self.read(|conn| {
let id = queries::resolve_page_identifier(conn, ident)?;
let page = queries::get_page(conn, id)?;
let project_identifier = page
.project_id
.and_then(|project_id| queries::get_project(conn, project_id).ok())
.map(|project| project.identifier);
Ok((page, project_identifier))
}) {
Ok((page, project_identifier)) => (
queries::activity::ActivityScope::Page(page.id),
page.project_id,
ActivityScopeReference::Page(page.id),
page.identifier,
project_identifier,
),
Err(e) => return format!("Error: {e}"),
}
} else if let Ok(issue) = self.read(|conn| {
let id = queries::resolve_identifier(conn, ident)?;
let issue = queries::get_issue(conn, id)?;
let project_identifier = queries::get_project(conn, issue.project_id)
.ok()
.map(|project| project.identifier);
Ok((issue, project_identifier))
}) {
let (issue, project_identifier) = issue;
(
queries::activity::ActivityScope::Issue(issue.id),
Some(issue.project_id),
ActivityScopeReference::Issue,
issue.identifier,
project_identifier,
)
} else {
match self.read(|conn| {
let id = queries::resolve_project_identifier(conn, ident)?;
queries::get_project(conn, id)
}) {
Ok(project) => (
queries::activity::ActivityScope::Project(project.id),
Some(project.id),
ActivityScopeReference::Project,
project.identifier,
None,
),
Err(_) => {
return format!(
"Error: '{ident}' is not a known issue, page, or project identifier"
);
}
}
};
if let Err(e) = require_page_role_mcp(&self.db, project_id, models::Role::Viewer) {
return format!("Error: {e}");
}
let link_context = current_issue_link_context();
let scope_reference = reference_with_context(
link_context.as_deref(),
match scope_reference {
ActivityScopeReference::Issue => ReferenceKind::Issue(&scope_identifier),
ActivityScopeReference::Page(page_id) => {
ReferenceKind::Page(&scope_identifier, page_id)
}
ActivityScopeReference::Project => ReferenceKind::Project(&scope_identifier),
},
);
let activity_project_identifier = project_identifier.as_deref().or(matches!(
scope_reference.kind,
ReferenceKind::Project(_)
)
.then_some(scope_identifier.as_str()));
match self.read(|conn| {
let feed = queries::activity::list_activity(conn, scope, Some(limit), Some(offset))?;
if feed.items.is_empty() {
return Ok((None, 0, feed.has_more));
}
let output = try_render(|output| {
writeln!(
output,
"{} activity entries for {scope_reference}:",
feed.items.len()
)?;
feed.items.iter().try_for_each(|activity| {
writeln!(
output,
"- {}",
ActivityLine {
conn,
activity,
project: activity_project_identifier,
context: link_context.as_deref(),
}
)
})
})
.map_err(|error| {
crate::error::LificError::Internal(format!(
"failed to format activity response: {error}"
))
})?;
Ok((Some(output), feed.items.len(), feed.has_more))
}) {
Ok((None, _, _)) if offset == 0 => render_response(|output| {
write!(output, "No recorded activity for {scope_reference} yet.")
}),
Ok((Some(mut out), _, has_more)) => {
let result = append_pagination_hint(&mut out, has_more, offset + limit);
finish_response(out, result)
}
Ok((None, _, _)) => "No activity entries in this range.".into(),
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "List issues for a project. workable=true gives issues with no blockers, blocked=true for issues with at least one blocker."
)]
fn list_issues(&self, Parameters(input): Parameters<ListIssuesInput>) -> String {
if let Some(nudge) = self.no_projects_nudge() {
return nudge;
}
let pid = match resolve_project(&self.db, &input.project) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Viewer) {
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,
blocked: input.blocked,
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 context = current_issue_link_context();
render_response(|output| {
writeln!(output, "{} issues:", issues.len())?;
issues.iter().try_for_each(|issue| {
writeln!(
output,
"- {}",
IssueLine {
issue,
context: context.as_deref(),
}
)
})?;
append_pagination_hint(output, has_more, offset + limit)
})
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Get an issue by ID (e.g. LIF-1): full details plus the last 3 comments by default."
)]
fn get_issue(&self, Parameters(input): Parameters<GetIssueInput>) -> String {
let comment_mode = input.include_comments.as_deref().unwrap_or("recent");
if !matches!(comment_mode, "recent" | "all" | "none") {
return format!(
"Error: invalid include_comments '{comment_mode}'. Use recent, all, or none."
);
}
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(),
};
let annotate = |idents: &[String]| -> Vec<String> {
idents
.iter()
.map(|ident| {
match queries::resolve_identifier(conn, ident)
.and_then(|iid| queries::issue_status(conn, iid))
{
Ok(status) => format!("{ident} ({status})"),
Err(_) => ident.clone(),
}
})
.collect()
};
let rels = AnnotatedRelations {
blocks: annotate(&issue.blocks),
blocked_by: annotate(&issue.blocked_by),
relates_to: annotate(&issue.relates_to),
duplicates: annotate(&issue.duplicates),
duplicated_by: annotate(&issue.duplicated_by),
};
Ok((issue, module_name, rels))
}) {
Ok((issue, module_name, rels)) => {
if let Err(e) = require_role_mcp(&self.db, issue.project_id, models::Role::Viewer) {
return format!("Error: {e}");
}
let context = current_issue_link_context();
let comments = self
.read(|conn| {
queries::comments::list_comments(
conn,
queries::comments::CommentParent::Issue(issue.id),
None,
None,
)
})
.ok()
.filter(|comments| !comments.is_empty());
render_response(|output| {
writeln!(
output,
"{} — {}\nStatus: {} | Priority: {} | Module: {}",
issue_reference(context.as_deref(), &issue.identifier),
issue.title,
issue.status,
issue.priority,
module_name
)?;
issue.labels.split_first().map_or(Ok(()), |_| {
writeln!(
output,
"Labels: {}",
JoinedStrings {
values: &issue.labels,
separator: ", ",
}
)
})?;
[
("Blocks: ", rels.blocks.as_slice()),
("Blocked by: ", rels.blocked_by.as_slice()),
("Relates to: ", rels.relates_to.as_slice()),
("Duplicates: ", rels.duplicates.as_slice()),
("Duplicated by: ", rels.duplicated_by.as_slice()),
]
.into_iter()
.try_for_each(|(label, values)| {
write!(
output,
"{}",
IssueReferenceList {
label,
values,
context: context.as_deref(),
}
)
})?;
(!issue.description.is_empty())
.then(|| writeln!(output, "\n{}", issue.description))
.transpose()?;
comments.as_ref().map_or(Ok(()), |comments| {
let total = comments.len();
let parent = queries::comments::CommentParent::Issue(issue.id);
match comment_mode {
"none" => writeln!(
output,
"\n--- Comments ({total}, omitted — use list_comments) ---"
),
"recent" if total > 3 => {
writeln!(
output,
"\n--- Comments ({total}, showing last 3 — use list_comments) ---"
)?;
write!(
output,
"{}",
CommentLines {
comments: &comments[total - 3..],
parent_identifier: &issue.identifier,
parent,
context: context.as_deref(),
}
)
}
_ => {
writeln!(output, "\n--- Comments ({total}) ---")?;
write!(
output,
"{}",
CommentLines {
comments,
parent_identifier: &issue.identifier,
parent,
context: context.as_deref(),
}
)
}
}
})
})
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Export as markdown: an issue (PRO-42), a page (PRO-DOC-3), or a whole project (PRO). Issues and pages return the markdown; projects return the exported file paths."
)]
fn export(&self, Parameters(input): Parameters<ExportInput>) -> String {
let ident = input.identifier.trim();
if looks_like_page_identifier(ident) {
let project_id = match self.read(|conn| {
let id = queries::resolve_page_identifier(conn, ident)?;
Ok(queries::get_page(conn, id)?.project_id)
}) {
Ok(pid) => pid,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_page_role_mcp(&self.db, project_id, models::Role::Viewer) {
return format!("Error: {e}");
}
match self.read(|conn| crate::export::export_page(conn, ident)) {
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}"),
}
} else if let Ok(project_id) = self.read(|conn| {
let id = queries::resolve_identifier(conn, ident)?;
Ok(queries::get_issue(conn, id)?.project_id)
}) {
if let Err(e) = require_role_mcp(&self.db, project_id, models::Role::Viewer) {
return format!("Error: {e}");
}
match self.read(|conn| crate::export::export_issue(conn, ident)) {
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}"),
}
} else {
let pid = match resolve_project(&self.db, ident) {
Ok(id) => id,
Err(_) => {
return format!(
"Error: '{ident}' is not a known issue, page, or project identifier"
);
}
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Viewer) {
return format!("Error: {e}");
}
match self.read(|conn| crate::export::export_project(conn, ident)) {
Ok(bundle) => render_response(|output| {
writeln!(output, "{} exported file(s):", bundle.files.len())?;
bundle
.files
.iter()
.try_for_each(|file| writeln!(output, "- {}", file.path))
}),
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}"),
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Maintainer) {
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: input.start_date.clone(),
target_date: input.target_date.clone(),
labels: input.labels.clone().unwrap_or_default(),
source: None,
},
)
}) {
Ok(issue) => {
self.emit(crate::realtime::RealtimeEvent::IssueCreated {
project_id: issue.project_id,
issue_id: issue.id,
});
let context = current_issue_link_context();
render_response(|output| {
write!(
output,
"Created {}: {}",
issue_reference(context.as_deref(), &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 {
let (id, project_id) = match self.read(|conn| {
let id = queries::resolve_identifier(conn, &input.identifier)?;
let project_id = queries::get_issue(conn, id)?.project_id;
Ok((id, project_id))
}) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_role_mcp(&self.db, project_id, models::Role::Maintainer) {
return format!("Error: {e}");
}
match self.write(|conn| {
let previous_issue = queries::get_issue(conn, id)?;
let audit_checkpoint = input
.status
.as_ref()
.map(|_| {
conn.query_row("SELECT COALESCE(MAX(id), 0) FROM audit_log", [], |row| {
row.get(0)
})
})
.transpose()?;
let module_id = match &input.module {
Some(name) if name.is_empty() => Some(None),
Some(name) => Some(Some(queries::resolve_module_name(
conn,
previous_issue.project_id,
name,
)?)),
None => None,
};
let issue = 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: input.start_date.clone(),
target_date: input.target_date.clone(),
labels: input.labels.clone(),
},
)?;
let cascade_action = match (previous_issue.status.as_str(), issue.status.as_str()) {
(previous, "done") if previous != "done" => {
Some(PlanStepCascadeAction::AutoComplete)
}
("done", current) if current != "done" => Some(PlanStepCascadeAction::Reopen),
_ => None,
};
let cascaded_steps = match (cascade_action, audit_checkpoint) {
(Some(action), Some(checkpoint)) => {
issue_status_cascaded_plan_steps(conn, checkpoint, id, action)?
}
_ => Vec::new(),
};
Ok((issue, cascade_action, cascaded_steps))
}) {
Ok((issue, cascade_action, cascaded_steps)) => {
self.emit(crate::realtime::RealtimeEvent::IssueUpdated {
project_id: issue.project_id,
issue_id: issue.id,
});
let context = current_issue_link_context();
render_response(|output| {
write!(
output,
"Updated {}: {}",
IssueReference {
context: context.as_deref(),
identifier: &issue.identifier,
},
IssueLine {
issue: &issue,
context: context.as_deref(),
}
)?;
cascade_action.map_or(Ok(()), |action| {
write!(
output,
"{}",
IssuePlanStepCascade {
action,
steps: &cascaded_steps,
context: context.as_deref(),
}
)
})
})
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Apply field changes to matching issues in one call. At most 500 matching issues are selected; narrow filters when more matches exist. Returns the number of issues updated."
)]
fn bulk_update(&self, Parameters(input): Parameters<BulkUpdateInput>) -> String {
let pid = match resolve_project(&self.db, &input.project) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Maintainer) {
return format!("Error: {e}");
}
let filter_module_id = match &input.filter_module {
Some(name) => match resolve_module(&self.db, pid, name) {
Ok(id) => Some(id),
Err(e) => return format!("Error: {e}"),
},
None => None,
};
let set_module_id = match &input.set_module {
Some(name) => match resolve_module(&self.db, pid, name) {
Ok(id) => Some(id),
Err(e) => return format!("Error: {e}"),
},
None => None,
};
const BULK_CAP: i64 = 500;
match self.write(|conn| {
let issues = queries::list_issues(
conn,
&models::ListIssuesQuery {
project_id: Some(pid),
status: input.filter_status.clone(),
priority: input.filter_priority.clone(),
module_id: filter_module_id,
label: input.filter_label.clone(),
limit: Some(BULK_CAP),
..Default::default()
},
)?;
issues
.iter()
.map(|issue| {
queries::update_issue(
conn,
issue.id,
&models::UpdateIssue {
title: None,
description: None,
status: input.set_status.clone(),
priority: input.set_priority.clone(),
module_id: set_module_id.map(Some),
sort_order: None,
start_date: None,
target_date: None,
labels: None,
},
)
.map(|issue| (issue.project_id, issue.id))
})
.collect::<Result<Vec<_>, _>>()
}) {
Ok(events) => {
for (project_id, issue_id) in &events {
self.emit(crate::realtime::RealtimeEvent::IssueUpdated {
project_id: *project_id,
issue_id: *issue_id,
});
}
format!("Updated {} issue(s)", events.len())
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Edit an issue by replacing an exact string. Targets the description by default; pass field='title' for the title. Fails if old_string is missing or ambiguous (unless replace_all=true). Cheaper than update_issue for small changes."
)]
fn edit_issue(&self, Parameters(input): Parameters<EditIssueInput>) -> String {
let (id, project_id) = match self.read(|conn| {
let id = queries::resolve_identifier(conn, &input.identifier)?;
let project_id = queries::get_issue(conn, id)?.project_id;
Ok((id, project_id))
}) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_role_mcp(&self.db, project_id, models::Role::Maintainer) {
return format!("Error: {e}");
}
match self.write(|conn| {
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) => {
self.emit(crate::realtime::RealtimeEvent::IssueUpdated {
project_id: issue.project_id,
issue_id: issue.id,
});
let context = current_issue_link_context();
render_response(|output| {
write!(
output,
"Edited {}: {}",
IssueReference {
context: context.as_deref(),
identifier: &issue.identifier,
},
IssueLine {
issue: &issue,
context: context.as_deref(),
}
)
})
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Board view of issues grouped by status (default), priority, or module. Done/cancelled are count-only stubs unless include_closed=true."
)]
fn get_board(&self, Parameters(input): Parameters<GetBoardInput>) -> String {
if let Some(nudge) = self.no_projects_nudge() {
return nudge;
}
let pid = match resolve_project(&self.db, &input.project) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Viewer) {
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 include_closed = input.include_closed.unwrap_or(false);
let is_closed = |i: &models::Issue| i.status == "done" || i.status == "cancelled";
let mut closed_omitted = 0i64;
if !include_closed && group_by != "status" {
let before = issues.len();
issues.retain(|i| !is_closed(i));
closed_omitted = (before - issues.len()) as i64;
}
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 context = current_issue_link_context();
let max_per_column = input.max_per_column.filter(|n| *n >= 0);
render_response(|output| {
truncated
.then(|| {
writeln!(
output,
"warning: board view capped at {BOARD_CAP} issues — older issues are not shown. Use list_issues with offset for full paging.\n"
)
})
.transpose()?;
ordered.into_iter().try_for_each(|(group, items)| {
let closed_stub = !include_closed
&& group_by == "status"
&& matches!(group.as_str(), "done" | "cancelled");
if closed_stub {
return writeln!(
output,
"── {} ({}) ── [omitted — pass include_closed=true]\n",
group,
items.len()
);
}
writeln!(output, "── {} ({}) ──", group, items.len())?;
let shown = max_per_column
.map_or(items.len(), |limit| (limit as usize).min(items.len()));
items[..shown].iter().try_for_each(|issue| {
writeln!(
output,
" {}",
IssueLine {
issue,
context: context.as_deref(),
}
)
})?;
(shown < items.len())
.then(|| {
writeln!(
output,
" … +{} more (use list_issues)",
items.len() - shown
)
})
.transpose()?;
output.write_char('\n')
})?;
(closed_omitted > 0)
.then(|| {
writeln!(
output,
"({closed_omitted} closed issues omitted — pass include_closed=true)"
)
})
.transpose()
.map(|_| ())
})
}
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 {
let (source, target) = match self.read(|conn| {
let source_id = queries::resolve_identifier(conn, &input.source)?;
let target_id = queries::resolve_identifier(conn, &input.target)?;
Ok((
queries::get_issue(conn, source_id)?,
queries::get_issue(conn, target_id)?,
))
}) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_role_mcp(&self.db, source.project_id, models::Role::Maintainer) {
return format!("Error: {e}");
}
if let Err(e) = require_role_mcp(&self.db, target.project_id, models::Role::Maintainer) {
return format!("Error: {e}");
}
match self
.write(|conn| queries::link_issues(conn, source.id, target.id, &input.relation_type))
{
Ok(()) => {
self.emit(crate::realtime::RealtimeEvent::IssueLinked {
project_id: source.project_id,
issue_id: source.id,
});
self.emit(crate::realtime::RealtimeEvent::IssueLinked {
project_id: target.project_id,
issue_id: target.id,
});
let context = current_issue_link_context();
render_response(|output| {
write!(
output,
"{} {} {}",
issue_reference(context.as_deref(), &source.identifier),
input.relation_type,
issue_reference(context.as_deref(), &target.identifier)
)
})
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Remove a relation between two issues")]
fn unlink_issues(&self, Parameters(input): Parameters<UnlinkIssuesInput>) -> String {
let (source, target) = match self.read(|conn| {
let source_id = queries::resolve_identifier(conn, &input.source)?;
let target_id = queries::resolve_identifier(conn, &input.target)?;
Ok((
queries::get_issue(conn, source_id)?,
queries::get_issue(conn, target_id)?,
))
}) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_role_mcp(&self.db, source.project_id, models::Role::Maintainer) {
return format!("Error: {e}");
}
if let Err(e) = require_role_mcp(&self.db, target.project_id, models::Role::Maintainer) {
return format!("Error: {e}");
}
match self.write(|conn| queries::unlink_issues(conn, source.id, target.id)) {
Ok(()) => {
self.emit(crate::realtime::RealtimeEvent::IssueUnlinked {
project_id: source.project_id,
issue_id: source.id,
});
self.emit(crate::realtime::RealtimeEvent::IssueUnlinked {
project_id: target.project_id,
issue_id: target.id,
});
let context = current_issue_link_context();
render_response(|output| {
write!(
output,
"Unlinked {} and {}",
issue_reference(context.as_deref(), &source.identifier),
issue_reference(context.as_deref(), &target.identifier)
)
})
}
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)) => {
if let Err(e) =
require_page_role_mcp(&self.db, page.project_id, models::Role::Viewer)
{
return format!("Error: {e}");
}
let context = current_issue_link_context();
render_response(|output| {
writeln!(
output,
"{}{} — {}\nStatus: {} | Folder: {}\nCreated: {} | Updated: {}",
if page.pinned { "📌 " } else { "" },
page_reference(context.as_deref(), &page),
page.title,
page.status,
folder_name.as_deref().unwrap_or("none"),
page.created_at,
page.updated_at
)?;
page.labels.split_first().map_or(Ok(()), |_| {
writeln!(
output,
"Labels: {}",
JoinedStrings {
values: &page.labels,
separator: ", ",
}
)
})?;
(!page.content.is_empty())
.then(|| writeln!(output, "\n{}", page.content))
.transpose()
.map(|_| ())
})
}
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,
};
if let Err(e) = require_page_role_mcp(&self.db, project_id, models::Role::Maintainer) {
return format!("Error: {e}");
}
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) => {
if let Some(project_id) = page.project_id {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated { project_id });
}
let context = current_issue_link_context();
render_response(|output| {
write!(
output,
"Created {}: {}",
page_reference(context.as_deref(), &page),
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 {
let (id, project_id) = match self.read(|conn| {
let id = queries::resolve_page_identifier(conn, &input.identifier)?;
let project_id = queries::get_page(conn, id)?.project_id;
Ok((id, project_id))
}) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_page_role_mcp(&self.db, project_id, models::Role::Maintainer) {
return format!("Error: {e}");
}
match self.write(|conn| {
let folder_id = match &input.folder {
Some(name) if name.is_empty() => Some(None),
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(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,
sort_order: None,
status: input.status.clone(),
pinned: input.pinned,
labels: input.labels.clone(),
},
)
}) {
Ok(page) => {
if let Some(project_id) = page.project_id {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated { project_id });
}
let context = current_issue_link_context();
render_response(|output| {
write!(
output,
"Updated {}: {}",
page_reference(context.as_deref(), &page),
page.title
)
})
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Edit a page by exact string replacement; same contract as edit_issue. Targets the content by default; pass field='title' for the title."
)]
fn edit_page(&self, Parameters(input): Parameters<EditPageInput>) -> String {
let (id, project_id) = match self.read(|conn| {
let id = queries::resolve_page_identifier(conn, &input.identifier)?;
let project_id = queries::get_page(conn, id)?.project_id;
Ok((id, project_id))
}) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_page_role_mcp(&self.db, project_id, models::Role::Maintainer) {
return format!("Error: {e}");
}
match self.write(|conn| {
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,
pinned: None,
labels: None,
};
match field {
"title" => patch.title = Some(updated),
"content" => patch.content = Some(updated),
_ => unreachable!(),
}
queries::update_page(conn, id, &patch)
}) {
Ok(page) => {
if let Some(project_id) = page.project_id {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated { project_id });
}
let context = current_issue_link_context();
render_response(|output| {
write!(
output,
"Edited {}: {}",
page_reference(context.as_deref(), &page),
page.title
)
})
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Delete any resource by type and identifier. Types: issue, page, plan, project, module, label, folder."
)]
fn delete(&self, Parameters(input): Parameters<DeleteInput>) -> String {
match input.resource_type.as_str() {
"issue" => {
let issue = match self.read(|conn| {
let id = queries::resolve_identifier(conn, &input.identifier)?;
queries::get_issue(conn, id)
}) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) =
require_role_mcp(&self.db, issue.project_id, models::Role::Maintainer)
{
return format!("Error: {e}");
}
match self.write(|conn| queries::delete_issue(conn, issue.id)) {
Ok(()) => {
self.emit(crate::realtime::RealtimeEvent::IssueDeleted {
project_id: issue.project_id,
issue_id: issue.id,
});
format!("Deleted issue {}", issue.identifier)
}
Err(e) => format!("Error: {e}"),
}
}
"plan" => {
let plan = match self.read(|conn| {
let id = queries::plans::resolve_plan_identifier(conn, &input.identifier)?;
queries::plans::get_plan(conn, id)
}) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) =
require_role_mcp(&self.db, plan.project_id, models::Role::Maintainer)
{
return format!("Error: {e}");
}
match self.write(|conn| queries::plans::delete_plan(conn, plan.id)) {
Ok(()) => {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: plan.project_id,
});
format!("Deleted plan {}", plan.identifier)
}
Err(e) => format!("Error: {e}"),
}
}
"page" => {
let page = match self.read(|conn| {
let id = queries::resolve_page_identifier(conn, &input.identifier)?;
queries::get_page(conn, id)
}) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) =
require_page_role_mcp(&self.db, page.project_id, models::Role::Maintainer)
{
return format!("Error: {e}");
}
match self.write(|conn| queries::delete_page(conn, page.id)) {
Ok(()) => {
if let Some(project_id) = page.project_id {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id,
});
}
format!("Deleted page {}", page.identifier)
}
Err(e) => format!("Error: {e}"),
}
}
"project" => {
let id = match self
.read(|conn| queries::resolve_project_identifier(conn, &input.identifier))
{
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_project_delete_role_mcp(&self.db, id) {
return format!("Error: {e}");
}
match self.write(|conn| queries::delete_project_with_audience(conn, id)) {
Ok((project, audience)) => {
let event = crate::realtime::RealtimeEvent::ProjectDeleted {
project_id: project.id,
};
match audience {
Some(user_ids) => self.realtime.send_to_users(event, user_ids),
None => self.emit(event),
}
format!("Deleted project {}", project.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}"),
};
if let Err(e) = require_structure_role_mcp(&self.db, pid) {
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)?;
let module = queries::get_module(conn, id)?;
queries::delete_module(conn, id)?;
Ok(module.name)
}),
"label" => self.write(|conn| {
let id = queries::resolve_label_name(conn, pid, &input.identifier)?;
queries::delete_label(conn, id)?;
Ok(format!("'{}'", input.identifier))
}),
"folder" => self.write(|conn| {
let id = queries::resolve_folder_name(conn, pid, &input.identifier)?;
queries::delete_folder(conn, id)?;
Ok(format!("'{}'", input.identifier))
}),
_ => unreachable!(),
};
match result {
Ok(reference) => {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: pid,
});
format!("Deleted {} {reference}", input.resource_type)
}
Err(e) => format!("Error: {e}"),
}
}
other => format!(
"Unknown type '{other}'. Use issue, page, plan, project, module, label, or folder."
),
}
}
#[tool(
description = "List resources by type: project, module, label, folder, page, issue, or plan. Most types need a project identifier."
)]
fn list_resources(&self, Parameters(input): Parameters<ListResourcesInput>) -> String {
let context = current_issue_link_context();
match input.resource_type.as_str() {
"plan" => {
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}"),
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Viewer) {
return format!("Error: {e}");
}
let limit = input.limit.unwrap_or(50).clamp(1, 500);
let offset = input.offset.unwrap_or(0).max(0);
match self.read(|conn| {
queries::plans::list_plans(
conn,
&models::ListPlansQuery {
project_id: Some(pid),
status: input.status.clone(),
limit: Some(limit),
offset: Some(offset),
..Default::default()
},
)
}) {
Ok(plans) if plans.is_empty() => "No plans found.".into(),
Ok(plans) => render_response(|output| {
writeln!(output, "{} plans:", plans.len())?;
plans.iter().try_for_each(|plan| {
write!(
output,
"- {} | {} | {} ({}/{} done)",
plan_reference(context.as_deref(), plan),
plan.status,
plan.title,
plan.done_count,
plan.step_count
)?;
plan.anchor_identifier.as_deref().map_or(Ok(()), |anchor| {
write!(
output,
" — anchor {}",
issue_reference(context.as_deref(), anchor)
)
})?;
output.write_char('\n')
})
}),
Err(e) => format!("Error: {e}"),
}
}
"project" => {
if let Some(nudge) = self.no_projects_nudge() {
return nudge;
}
let visible = match visible_project_ids_mcp(&self.db) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
match self.read(|conn| {
Ok((
queries::list_projects(conn)?,
queries::project_agent_stats(conn)?,
))
}) {
Ok((ps, stats)) => {
let mut ps = filter_visible(ps, &visible, |p| Some(p.id));
ps.sort_by(|left, right| cmp_projects_by_activity(left, right, &stats));
let now = Utc::now();
render_response(|output| {
writeln!(output, "{} projects:", ps.len())?;
ps.iter().try_for_each(|project| {
write!(
output,
"- {} | {}",
project_reference(context.as_deref(), &project.identifier,),
project.name
)?;
(!project.description.is_empty())
.then(|| write!(output, " — {}", project.description))
.transpose()?;
stats.get(&project.id).map_or(Ok(()), |stats| {
write!(output, "{}", ProjectAgentStats { stats, now })
})?;
output.write_char('\n')
})
})
}
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}"),
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Viewer) {
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);
}
render_response(|output| {
writeln!(
output,
"{} issues (use list_issues for filtering):",
issues.len()
)?;
issues.iter().try_for_each(|issue| {
writeln!(
output,
"- {} | {} | {}",
issue_reference(context.as_deref(), &issue.identifier),
issue.status,
issue.title
)
})?;
append_pagination_hint(output, has_more, offset + limit)
})
}
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 visible = if let Some(pid) = project_id {
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Viewer) {
return format!("Error: {e}");
}
None
} else {
match visible_project_ids_mcp(&self.db) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
}
};
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();
let limit = input.limit.unwrap_or(100).max(1);
let offset = input.offset.unwrap_or(0).max(0);
match self.read(|conn| {
let pages = queries::list_pages(
conn,
project_id,
folder_id,
label,
status,
order_by,
order,
Some(limit + 1),
Some(offset),
)?;
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, folder_names)) => {
let mut pages = filter_visible(pages, &visible, |p| p.project_id);
if pages.is_empty() {
return "No pages found.".into();
}
let has_more = pages.len() as i64 > limit;
if has_more {
pages.truncate(limit as usize);
}
render_response(|output| {
writeln!(output, "{} pages:", pages.len())?;
pages.iter().try_for_each(|page| {
let updated = page
.updated_at
.split(' ')
.next()
.unwrap_or(&page.updated_at);
let pin = if page.pinned { "📌 " } else { "" };
write!(
output,
"- {pin}{} | {} | {}",
page_reference(context.as_deref(), page),
page.status,
page.title
)?;
page.labels.split_first().map_or(Ok(()), |_| {
write!(
output,
" [{}]",
JoinedStrings {
values: &page.labels,
separator: ", ",
}
)
})?;
page.folder_id
.and_then(|folder_id| folder_names.get(&folder_id))
.map_or(Ok(()), |folder| {
write!(output, " (folder: {folder})")
})?;
writeln!(output, " — updated {updated}")
})?;
append_pagination_hint(output, has_more, offset + limit)
})
}
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}"),
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Viewer) {
return format!("Error: {e}");
}
let canonical_project = match canonical_project_identifier(&self.db, pid) {
Ok(identifier) => identifier,
Err(e) => return format!("Error: {e}"),
};
match self.read(|conn| queries::list_modules(conn, pid)) {
Ok(modules) => render_response(|output| {
writeln!(output, "{} modules:", modules.len())?;
modules.iter().try_for_each(|module| {
write!(
output,
"- {} ({})",
module_reference(context.as_deref(), &canonical_project, module,),
module.status
)?;
(!module.description.is_empty())
.then(|| write!(output, " — {}", module.description))
.transpose()?;
output.write_char('\n')
})
}),
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}"),
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Viewer) {
return format!("Error: {e}");
}
match self.read(|conn| queries::list_labels(conn, pid)) {
Ok(labels) => render_response(|output| {
writeln!(output, "{} labels:", labels.len())?;
labels.iter().try_for_each(|label| {
writeln!(output, "- {} ({})", label.name, label.color)
})
}),
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}"),
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Viewer) {
return format!("Error: {e}");
}
match self.read(|conn| queries::list_folders(conn, pid)) {
Ok(folders) => render_response(|output| {
writeln!(output, "{} folders:", folders.len())?;
folders.iter().try_for_each(|folder| {
writeln!(output, "- [{}] {}", folder.id, folder.name)
})
}),
Err(e) => format!("Error: {e}"),
}
}
other => format!(
"Unknown type '{other}'. Use project, module, label, folder, page, issue, or plan."
),
}
}
#[tool(
description = "Create or update a project, module, label, or folder. Create project requires name and identifier; project update requires project=<IDENT>; module/label/folder create requires project and name; module/label/folder update requires project and current_name. Use delete for deletion."
)]
fn manage_resource(&self, Parameters(input): Parameters<ManageResourceInput>) -> String {
let context = current_issue_link_context();
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: emoji_for_create(&input.emoji),
lead_user_id,
},
)
}) {
Ok(p) => {
self.emit(crate::realtime::RealtimeEvent::ProjectCreated {
project_id: p.id,
});
render_response(|output| {
write!(
output,
"Created project {} | {}",
project_reference(context.as_deref(), &p.identifier),
p.name
)
})
}
Err(e) => format!("Error: {e}"),
}
}
("project", "update") => {
if matches!((&input.current_name, &input.project), (Some(_), None)) {
return "Error: project updates must be targeted with project=<identifier>, not current_name"
.into();
}
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}"),
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Lead) {
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: emoji_for_update(&input.emoji),
lead_user_id: None,
},
)
}) {
Ok(p) => {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: p.id,
});
render_response(|output| {
write!(
output,
"Updated project {} | {}",
project_reference(context.as_deref(), &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}"),
};
if let Err(e) = require_structure_role_mcp(&self.db, pid) {
return format!("Error: {e}");
}
let canonical_project = match canonical_project_identifier(&self.db, pid) {
Ok(identifier) => identifier,
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: emoji_for_create(&input.emoji),
},
)
}) {
Ok(m) => {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: pid,
});
render_response(|output| {
write!(
output,
"Created module {}",
module_reference(context.as_deref(), &canonical_project, &m)
)
})
}
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}"),
};
if let Err(e) = require_structure_role_mcp(&self.db, pid) {
return format!("Error: {e}");
}
let canonical_project = match canonical_project_identifier(&self.db, pid) {
Ok(identifier) => identifier,
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: emoji_for_update(&input.emoji),
},
)
}) {
Ok(m) => {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: pid,
});
render_response(|output| {
write!(
output,
"Updated module {}",
module_reference(context.as_deref(), &canonical_project, &m)
)
})
}
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}"),
};
if let Err(e) = require_structure_role_mcp(&self.db, pid) {
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) => {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: pid,
});
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}"),
};
if let Err(e) = require_structure_role_mcp(&self.db, pid) {
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) => {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: pid,
});
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}"),
};
if let Err(e) = require_structure_role_mcp(&self.db, pid) {
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) => {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: pid,
});
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}"),
};
if let Err(e) = require_structure_role_mcp(&self.db, pid) {
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) => {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: pid,
});
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 (LIF-42) or page (LIF-DOC-3; DOC-3 for workspace pages). The author is the authenticated user."
)]
fn add_comment(&self, Parameters(input): Parameters<AddCommentInput>) -> String {
let (parent, parent_identifier, project_id) =
match resolve_comment_parent(self, &input.identifier) {
Ok(context) => context,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = self.require_comment_role_mcp(parent, models::Role::Viewer) {
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}"),
},
};
let member_scoped = match crate::authz::authz_enforced(&self.db) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
match self.write(|conn| {
let candidates =
queries::comments::mention_candidates(conn, project_id, member_scoped)?;
let c = queries::comments::create_comment(conn, parent, user_id, &input.content)?;
queries::comments::sync_mentions(conn, c.id, &c.content, &candidates)?;
let event = match (c.issue_id, project_id) {
(Some(issue_id), Some(project_id)) => {
Some(crate::realtime::RealtimeEvent::IssueUpdated {
project_id,
issue_id,
})
}
(None, Some(project_id)) => {
Some(crate::realtime::RealtimeEvent::ProjectUpdated { project_id })
}
(_, None) => None,
};
Ok((c, event))
}) {
Ok((c, event)) => {
event.into_iter().for_each(|event| self.emit(event));
match current_issue_link_context() {
Some(context) => render_response(|output| {
write!(
output,
"{} added to {} by {} at {}",
reference_with_context(
Some(context.as_ref()),
comment_reference_kind(&parent_identifier, parent, c.id),
),
reference_with_context(
Some(context.as_ref()),
comment_parent_reference_kind(&parent_identifier, parent),
),
c.author,
c.created_at
)
}),
None => render_response(|output| {
write!(
output,
"Comment #{} added to {} by {} at {}",
c.id, parent_identifier, c.author, c.created_at
)
}),
}
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "List comments on an issue (LIF-42) or page (LIF-DOC-3; DOC-3 for workspace pages)."
)]
fn list_comments(&self, Parameters(input): Parameters<ListCommentsInput>) -> String {
let (parent, parent_identifier, _) = match resolve_comment_parent(self, &input.identifier) {
Ok(context) => context,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = self.require_comment_role_mcp(parent, models::Role::Viewer) {
return format!("Error: {e}");
}
let limit = input.limit.map(|limit| limit.clamp(1, 500));
let offset = input.offset.unwrap_or(0).max(0);
let order = input.order.as_deref().unwrap_or("asc");
let link_context = current_issue_link_context();
let parent_kind = match parent {
queries::comments::CommentParent::Issue(_) => {
ReferenceKind::Issue(parent_identifier.as_str())
}
queries::comments::CommentParent::Page(page_id) => {
ReferenceKind::Page(parent_identifier.as_str(), page_id)
}
};
match self.read(|conn| {
let comments = queries::comments::list_comments_paginated(
conn,
parent,
input.author.as_deref(),
Some(order),
limit.map(|limit| limit + 1),
input.offset.map(|offset| offset.max(0)),
)?;
let total = queries::comments::count_comments(conn, parent, input.author.as_deref())?;
Ok((comments, total))
}) {
Ok((comments, total)) if comments.is_empty() => {
if total == 0 {
format!(
"No comments on {}.",
reference_with_context(link_context.as_deref(), parent_kind)
)
} else {
format!(
"No comments in this range on {} (offset {offset}, {total} total).",
reference_with_context(link_context.as_deref(), parent_kind)
)
}
}
Ok((mut comments, total)) => {
let page_limit = limit.filter(|&limit| comments.len() as i64 > limit);
let has_more = page_limit.is_some();
page_limit
.into_iter()
.for_each(|page_limit| comments.truncate(page_limit as usize));
let shown = comments.len() as i64;
render_response(|output| {
match (limit, offset, shown < total) {
(Some(_), 0, true) => {
let edge = if order == "desc" {
"most recent"
} else {
"oldest"
};
writeln!(
output,
"Showing {shown} {edge} of {total} comment(s) on {}:",
reference_with_context(link_context.as_deref(), parent_kind)
)?;
}
(_, offset, _) if offset > 0 => {
writeln!(
output,
"Showing comments {}-{} of {total} on {} ({} first):",
offset + 1,
offset + shown,
reference_with_context(link_context.as_deref(), parent_kind),
if order == "desc" { "newest" } else { "oldest" }
)?;
}
_ => {
writeln!(
output,
"{total} comment(s) on {}:",
reference_with_context(link_context.as_deref(), parent_kind)
)?;
}
}
write!(
output,
"{}",
CommentLines {
comments: &comments,
parent_identifier: &parent_identifier,
parent,
context: link_context.as_deref(),
}
)?;
has_more
.then(|| {
let next_offset = offset + shown;
let remaining = total.saturating_sub(next_offset);
writeln!(
output,
"\n... {remaining} more comment(s) — call again with the same author/order/limit and offset={next_offset}"
)
})
.transpose()
.map(|_| ())
})
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Edit a comment's content by id. Author or admin only; @mentions re-resolve."
)]
fn edit_comment(&self, Parameters(input): Parameters<EditCommentInput>) -> String {
let (user_id, is_admin) = match self.resolve_comment_actor() {
Ok(u) => u,
Err(e) => return format!("Error: {e}"),
};
let existing =
match self.read(|conn| queries::comments::get_comment(conn, input.comment_id)) {
Ok(c) => c,
Err(e) => return format!("Error: {e}"),
};
if existing.user_id != user_id && !is_admin {
return "Error: you can only edit your own comments".into();
}
let (project_id, parent, parent_identifier) =
match self.read(|conn| resolve_comment_context(conn, &existing)) {
Ok(context) => context,
Err(e) => return format!("Error: {e}"),
};
let member_scoped = match crate::authz::authz_enforced(&self.db) {
Ok(v) => v,
Err(e) => return format!("Error: {e}"),
};
match self.write(|conn| {
let candidates =
queries::comments::mention_candidates(conn, project_id, member_scoped)?;
let c = queries::comments::update_comment(conn, input.comment_id, &input.content)?;
queries::comments::sync_mentions(conn, c.id, &c.content, &candidates)?;
Ok(c)
}) {
Ok(c) => {
if let (Some(issue_id), Some(project_id)) = (c.issue_id, project_id) {
self.emit(crate::realtime::RealtimeEvent::IssueUpdated {
project_id,
issue_id,
});
} else if let Some(project_id) = project_id {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated { project_id });
}
match current_issue_link_context() {
Some(context) => render_response(|output| {
write!(
output,
"{} edited at {}",
reference_with_context(
Some(context.as_ref()),
comment_reference_kind(&parent_identifier, parent, c.id),
),
c.updated_at
)
}),
None => render_response(|output| {
write!(output, "Comment #{} edited at {}", c.id, c.updated_at)
}),
}
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Delete a comment by id. Author or admin only.")]
fn delete_comment(&self, Parameters(input): Parameters<DeleteCommentInput>) -> String {
let (user_id, is_admin) = match self.resolve_comment_actor() {
Ok(u) => u,
Err(e) => return format!("Error: {e}"),
};
let existing =
match self.read(|conn| queries::comments::get_comment(conn, input.comment_id)) {
Ok(c) => c,
Err(e) => return format!("Error: {e}"),
};
if existing.user_id != user_id && !is_admin {
return "Error: you can only delete your own comments".into();
}
let (project_id, parent, parent_identifier) =
match self.read(|conn| resolve_comment_context(conn, &existing)) {
Ok(context) => context,
Err(e) => return format!("Error: {e}"),
};
match self.write(|conn| queries::comments::delete_comment(conn, input.comment_id)) {
Ok(()) => {
if let (Some(issue_id), Some(project_id)) = (existing.issue_id, project_id) {
self.emit(crate::realtime::RealtimeEvent::IssueUpdated {
project_id,
issue_id,
});
} else if let Some(project_id) = project_id {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated { project_id });
}
match current_issue_link_context() {
Some(context) => render_response(|output| {
write!(
output,
"Comment #{} deleted from {}",
input.comment_id,
reference_with_context(
Some(context.as_ref()),
comment_parent_reference_kind(&parent_identifier, parent),
)
)
}),
None => render_response(|output| {
write!(output, "Comment #{} deleted", input.comment_id)
}),
}
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Create a nestable step-by-step plan that survives outside the context window. Steps can mirror issues via 'issue': closing the issue completes the step and vice versa."
)]
fn create_plan(&self, Parameters(input): Parameters<CreatePlanInput>) -> String {
let pid = match resolve_project(&self.db, &input.project) {
Ok(id) => id,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_role_mcp(&self.db, pid, models::Role::Maintainer) {
return format!("Error: {e}");
}
match self.write(|conn| {
let anchor = match &input.anchor_issue {
Some(ident) => Some(queries::resolve_identifier(conn, ident)?),
None => None,
};
let mut steps = Vec::new();
if let Some(input_steps) = &input.steps {
for s in input_steps {
steps.push(build_create_step(conn, s)?);
}
}
queries::plans::create_plan(
conn,
&models::CreatePlan {
project_id: pid,
title: input.title.clone(),
issue_id: anchor,
steps,
},
)
}) {
Ok(plan) => {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: plan.project_id,
});
let context = current_issue_link_context();
render_response(|output| {
writeln!(
output,
"Created {}",
plan_reference(context.as_deref(), &plan)
)?;
write!(
output,
"{}",
PlanView {
plan: &plan,
context: context.as_deref(),
}
)
})
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Rehydrate a plan's full step tree (e.g. LIF-PLAN-3) when resuming work. Step lines show the #id used by edit_plan_step and update_plan_step, done state, and linked issues."
)]
fn get_plan(&self, Parameters(input): Parameters<GetPlanInput>) -> String {
match self.read(|conn| {
let id = queries::plans::resolve_plan_identifier(conn, &input.plan)?;
queries::plans::get_plan(conn, id)
}) {
Ok(plan) => {
if let Err(e) = require_role_mcp(&self.db, plan.project_id, models::Role::Viewer) {
return format!("Error: {e}");
}
fmt_plan(&plan)
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Edit a plan step's text by exact string replacement; same contract as edit_issue. Targets description by default; pass field='title'."
)]
fn edit_plan_step(&self, Parameters(input): Parameters<EditPlanStepInput>) -> String {
let field = input.field.clone().unwrap_or_else(|| "description".into());
let plan_project_id = match self.read(|conn| {
let plan_id = queries::plans::resolve_plan_identifier(conn, &input.plan)?;
Ok(queries::plans::get_plan(conn, plan_id)?.project_id)
}) {
Ok(pid) => pid,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_role_mcp(&self.db, plan_project_id, models::Role::Maintainer) {
return format!("Error: {e}");
}
match self.write(|conn| {
let plan_id = queries::plans::resolve_plan_identifier(conn, &input.plan)?;
queries::plans::assert_step_in_plan(conn, plan_id, input.step_id)?;
let (find, replace) = if field == "description" {
(
queries::unescape_text(&input.old_string),
queries::unescape_text(&input.new_string),
)
} else {
(input.old_string.clone(), input.new_string.clone())
};
queries::plans::edit_step_text(
conn,
input.step_id,
&field,
&find,
&replace,
input.replace_all.unwrap_or(false),
)?;
queries::plans::get_plan(conn, plan_id)
}) {
Ok(plan) => {
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: plan.project_id,
});
let context = current_issue_link_context();
render_response(|output| {
writeln!(
output,
"Edited step #{} in {}",
input.step_id,
plan_reference(context.as_deref(), &plan)
)?;
write!(
output,
"{}",
PlanView {
plan: &plan,
context: context.as_deref(),
}
)
})
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(
description = "Mutate a plan or one step. With step_id: step CRUD, done toggling, attach/detach issue (linked issues sync state). Without step_id: update the plan itself; plan status never closes the anchor issue. Returns a delta."
)]
fn update_plan_step(&self, Parameters(input): Parameters<UpdatePlanStepInput>) -> String {
let plan_project_id = match self.read(|conn| {
let plan_id = queries::plans::resolve_plan_identifier(conn, &input.plan)?;
Ok(queries::plans::get_plan(conn, plan_id)?.project_id)
}) {
Ok(pid) => pid,
Err(e) => return format!("Error: {e}"),
};
if let Err(e) = require_role_mcp(&self.db, plan_project_id, models::Role::Maintainer) {
return format!("Error: {e}");
}
if let Some(ref ident) = input.attach_issue
&& let Err(e) = self.require_issue_ident_role_mcp(ident, models::Role::Maintainer)
{
return format!("Error: {e}");
}
if let Some(ref ident) = input.add_child_issue
&& let Err(e) = self.require_issue_ident_role_mcp(ident, models::Role::Maintainer)
{
return format!("Error: {e}");
}
if input.done == Some(true)
&& let Some(step_id) = input.step_id
&& let Err(e) = self.require_step_issue_role_mcp(step_id, models::Role::Maintainer)
{
return format!("Error: {e}");
}
let context = current_issue_link_context();
match self.write(|conn| {
let plan_id = queries::plans::resolve_plan_identifier(conn, &input.plan)?;
let mut notes: Vec<String> = Vec::new();
let mut events = Vec::new();
match input.step_id {
None => {
let anchor = match (&input.anchor_issue, input.clear_anchor) {
(Some(ident), _) => Some(Some(queries::resolve_identifier(conn, ident)?)),
(None, Some(true)) => Some(None),
_ => None,
};
queries::plans::update_plan(
conn,
plan_id,
&models::UpdatePlan {
title: input.title.clone(),
status: input.status.clone(),
issue_id: anchor,
},
)?;
notes.push("Updated plan".into());
}
Some(step_id) => {
queries::plans::assert_step_in_plan(conn, plan_id, step_id)?;
if input.delete.unwrap_or(false) {
queries::plans::delete_step(conn, step_id)?;
notes.push(format!("Deleted step #{step_id} (and its subtree)"));
} else {
if let Some(ref t) = input.title {
queries::plans::set_step_title(conn, step_id, t)?;
notes.push(format!("Renamed step #{step_id}"));
}
if let Some(ref ident) = input.attach_issue {
let iid = queries::resolve_identifier(conn, ident)?;
let issue = queries::get_issue(conn, iid)?;
queries::plans::set_step_issue(conn, step_id, Some(iid))?;
notes.push(
try_render(|output| {
write!(
output,
"Attached {} to step #{step_id}",
issue_reference(context.as_deref(), &issue.identifier,)
)
})
.map_err(|error| {
crate::error::LificError::Internal(format!(
"failed to format plan-step response: {error}"
))
})?,
);
}
if input.detach_issue.unwrap_or(false) {
queries::plans::set_step_issue(conn, step_id, None)?;
notes.push(format!("Detached issue from step #{step_id}"));
}
if let Some(done) = input.done {
let effect = queries::plans::set_step_done(conn, step_id, done)?;
enum IssueEffect<'a> {
MarkedDone(&'a str),
AlreadyDone(&'a str),
None,
}
let issue_effect = match effect.issue_identifier.as_deref() {
Some(identifier) if effect.issue_status_changed => {
let issue_id = queries::resolve_identifier(conn, identifier)?;
let issue = queries::get_issue(conn, issue_id)?;
events.push(crate::realtime::RealtimeEvent::IssueUpdated {
project_id: issue.project_id,
issue_id: issue.id,
});
IssueEffect::MarkedDone(identifier)
}
Some(identifier) if done => IssueEffect::AlreadyDone(identifier),
_ => IssueEffect::None,
};
let message = try_render(|output| {
write!(
output,
"Step #{step_id} {}",
if done { "marked done" } else { "reopened" }
)?;
match issue_effect {
IssueEffect::MarkedDone(identifier) => write!(
output,
" → {} marked done",
issue_reference(context.as_deref(), identifier)
),
IssueEffect::AlreadyDone(identifier) => write!(
output,
" (linked {} already done)",
issue_reference(context.as_deref(), identifier)
),
IssueEffect::None => Ok(()),
}
})
.map_err(|error| {
crate::error::LificError::Internal(format!(
"failed to format plan-step response: {error}"
))
})?;
notes.push(message);
}
if let Some(ref child_title) = input.add_child_title {
let child_issue = match &input.add_child_issue {
Some(ident) => Some(queries::resolve_identifier(conn, ident)?),
None => None,
};
let child_id = queries::plans::add_step(
conn,
plan_id,
Some(step_id),
child_title,
input.add_child_description.as_deref().unwrap_or(""),
child_issue,
)?;
notes.push(format!("Added child step #{child_id}"));
}
if input.move_to_root.unwrap_or(false)
|| input.move_parent_step_id.is_some()
|| input.move_position.is_some()
{
let new_parent = if input.move_to_root.unwrap_or(false) {
None
} else if let Some(p) = input.move_parent_step_id {
Some(p)
} else {
queries::plans::step_parent(conn, step_id)?
};
queries::plans::move_step(
conn,
step_id,
new_parent,
input.move_position,
)?;
notes.push(format!("Moved step #{step_id}"));
}
if notes.is_empty() {
notes.push("No changes specified".into());
}
}
}
}
let plan = queries::plans::get_plan(conn, plan_id)?;
Ok((notes, plan, events))
}) {
Ok((notes, plan, events)) => {
events.into_iter().for_each(|event| self.emit(event));
self.emit(crate::realtime::RealtimeEvent::ProjectUpdated {
project_id: plan.project_id,
});
render_response(|output| {
writeln!(
output,
"{}",
JoinedStrings {
values: ¬es,
separator: "; ",
}
)?;
match input.echo_tree.unwrap_or(false) {
true => write!(
output,
"{}",
PlanView {
plan: &plan,
context: context.as_deref(),
}
),
false => write!(
output,
"{} [{}]: {}/{} done",
plan_reference(context.as_deref(), &plan),
plan.status,
plan.done_count,
plan.step_count
),
}
})
}
Err(e) => format!("Error: {e}"),
}
}
}
fn build_create_step(
conn: &rusqlite::Connection,
s: &PlanStepInput,
) -> Result<models::CreatePlanStep, crate::error::LificError> {
let issue_id = match &s.issue {
Some(ident) => Some(queries::resolve_identifier(conn, ident)?),
None => None,
};
let mut steps = Vec::new();
if let Some(children) = &s.steps {
for c in children {
steps.push(build_create_step(conn, c)?);
}
}
Ok(models::CreatePlanStep {
title: s.title.clone(),
description: s.description.clone().unwrap_or_default(),
issue_id,
done: s.done.unwrap_or(false),
steps,
})
}
#[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 mcp_with_realtime() -> (
LificMcp,
tokio::sync::broadcast::Receiver<crate::realtime::RealtimeMessage>,
) {
let db = crate::db::open_memory().expect("test db");
let realtime = crate::realtime::RealtimeHub::new();
let rx = realtime.subscribe();
(LificMcp::with_realtime(db, realtime), rx)
}
fn drain_realtime(rx: &mut tokio::sync::broadcast::Receiver<crate::realtime::RealtimeMessage>) {
while rx.try_recv().is_ok() {}
}
fn realtime_events(
rx: &mut tokio::sync::broadcast::Receiver<crate::realtime::RealtimeMessage>,
) -> Vec<crate::realtime::RealtimeEvent> {
std::iter::from_fn(|| rx.try_recv().ok().map(|message| message.event)).collect()
}
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,
emoji: 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,
..Default::default()
}));
assert!(result.starts_with("Created"), "got: {result}");
result
}
fn project_id_for(mcp: &LificMcp, identifier: &str) -> i64 {
mcp.read(|conn| queries::resolve_project_identifier(conn, identifier))
.expect("project id")
}
#[test]
fn canonical_project_identifier_comes_from_project_record() {
let m = mcp();
seed_project(&m, "Canonical", "CAN");
let project_id = project_id_for(&m, "CAN");
assert_eq!(
canonical_project_identifier(&m.db, project_id).unwrap(),
"CAN"
);
}
fn issue_id_for(mcp: &LificMcp, identifier: &str) -> i64 {
mcp.read(|conn| queries::resolve_identifier(conn, identifier))
.expect("issue id")
}
pub(super) fn setup_membership_mcp() -> (
LificMcp,
models::AuthUser,
models::AuthUser,
models::AuthUser,
models::AuthUser,
models::AuthUser,
i64,
) {
let (db, admin, lead, maintainer, viewer, non_member, project_id) =
crate::api::test_helpers::setup_membership_test();
let mcp = LificMcp::new(db);
let au = |u: crate::db::models::User| models::AuthUser {
id: u.id,
username: u.username,
display_name: u.display_name,
is_admin: u.is_admin,
};
(
mcp,
au(admin),
au(lead),
au(maintainer),
au(viewer),
au(non_member),
project_id,
)
}
#[test]
fn project_resolver_matches_exact_identifiers_only() {
let m = mcp();
seed_project(&m, "Resolver Project", "RSL");
assert!(resolve_project(&m.db, "RSL").expect("exact identifier should resolve") > 0);
for identifier in ["rsl", "RS", "MISSING"] {
let error =
resolve_project(&m.db, identifier).expect_err("identifier should not resolve");
assert!(
error.contains(&format!("project '{identifier}' not found")),
"got: {error}"
);
}
}
#[test]
fn module_resolver_matches_names_case_insensitively_without_substrings() {
let m = mcp();
seed_project(&m, "Resolver Project", "MOD");
let project_id = resolve_project(&m.db, "MOD").expect("project should resolve");
let created = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "module".into(),
action: "create".into(),
project: Some("MOD".into()),
name: Some("Backend".into()),
..Default::default()
}));
assert!(created.starts_with("Created module"), "got: {created}");
let exact =
resolve_module(&m.db, project_id, "Backend").expect("exact name should resolve");
assert_eq!(
resolve_module(&m.db, project_id, "backend")
.expect("case-insensitive name should resolve"),
exact
);
for name in ["Back", "Missing"] {
let error =
resolve_module(&m.db, project_id, name).expect_err("name should not resolve");
assert!(
error.contains(&format!("module '{name}' not found in project")),
"got: {error}"
);
}
}
#[test]
fn folder_resolver_matches_names_case_insensitively_without_substrings() {
let m = mcp();
seed_project(&m, "Resolver Project", "FLD");
let project_id = resolve_project(&m.db, "FLD").expect("project should resolve");
let created = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "folder".into(),
action: "create".into(),
project: Some("FLD".into()),
name: Some("Documentation".into()),
..Default::default()
}));
assert!(created.starts_with("Created folder"), "got: {created}");
let exact =
resolve_folder(&m.db, project_id, "Documentation").expect("exact name should resolve");
assert_eq!(
resolve_folder(&m.db, project_id, "documentation")
.expect("case-insensitive name should resolve"),
exact
);
for name in ["Document", "Missing"] {
let error =
resolve_folder(&m.db, project_id, name).expect_err("name should not resolve");
assert!(
error.contains(&format!("folder '{name}' not found in project")),
"got: {error}"
);
}
}
#[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: Some("Old".into()),
status: None,
color: None,
emoji: None,
}));
assert!(result.contains("New Name"), "got: {result}");
}
#[test]
fn manage_update_project_description_persists() {
let m = mcp();
seed_project(&m, "Project", "DSC");
let project_id = project_id_for(&m, "DSC");
let result = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "update".into(),
project: Some("DSC".into()),
name: None,
identifier: None,
description: Some("Updated description".into()),
current_name: None,
status: None,
color: None,
emoji: None,
}));
assert!(result.contains("Updated project"), "got: {result}");
let project = m
.read(|conn| queries::get_project(conn, project_id))
.expect("updated project");
assert_eq!(project.description, "Updated description");
}
#[test]
fn manage_update_project_identifier_persists() {
let m = mcp();
seed_project(&m, "Project", "OLD");
let project_id = project_id_for(&m, "OLD");
let result = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "update".into(),
project: Some("OLD".into()),
name: None,
identifier: Some("NEW".into()),
description: None,
current_name: None,
status: None,
color: None,
emoji: None,
}));
assert!(result.contains("Updated project NEW"), "got: {result}");
let project = m
.read(|conn| queries::get_project(conn, project_id))
.expect("updated project");
assert_eq!(project.identifier, "NEW");
assert_eq!(project_id_for(&m, "NEW"), project_id);
}
#[test]
fn manage_update_project_with_current_name_requires_project_identifier() {
let m = mcp();
seed_project(&m, "Original", "ORG");
let project_id = project_id_for(&m, "ORG");
let result = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "update".into(),
project: None,
name: Some("Changed".into()),
identifier: Some("NEW".into()),
description: Some("Changed description".into()),
current_name: Some("Original".into()),
status: None,
color: None,
emoji: None,
}));
assert_eq!(
result,
"Error: project updates must be targeted with project=<identifier>, not current_name"
);
let project = m
.read(|conn| queries::get_project(conn, project_id))
.expect("unchanged project");
assert_eq!(project.name, "Original");
assert_eq!(project.identifier, "ORG");
assert_eq!(project.description, "");
}
#[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,
emoji: 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,
emoji: 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,
emoji: 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,
emoji: 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,
emoji: 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(),
..Default::default()
}));
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,
emoji: 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()]),
..Default::default()
}));
assert!(result.contains("OPT-1"), "got: {result}");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "OPT-1".into(),
..Default::default()
}));
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,
..Default::default()
}));
assert!(result.contains("Renamed"), "got: {result}");
assert!(result.contains("active"), "got: {result}");
assert!(result.contains("urgent"), "got: {result}");
}
#[test]
fn update_issue_without_linked_plan_steps_has_plain_response() {
let m = mcp();
seed_project(&m, "Test", "PLN");
seed_issue(&m, "PLN", "Standalone");
let result = m.update_issue(Parameters(UpdateIssueInput {
identifier: "PLN-1".into(),
status: Some("done".into()),
..Default::default()
}));
assert_eq!(
result, "Updated PLN-1: PLN-1 | done | none | Standalone",
"unlinked issue must not invent a cascade note"
);
}
#[test]
fn create_issue_persists_target_date() {
let m = mcp();
seed_project(&m, "Test", "SCH");
let result = m.create_issue(Parameters(CreateIssueInput {
project: "SCH".into(),
title: "Scheduled".into(),
target_date: Some("2026-06-15".into()),
..Default::default()
}));
assert!(result.contains("SCH-1"), "got: {result}");
let conn = m.db.read().unwrap();
let id = queries::resolve_identifier(&conn, "SCH-1").unwrap();
let issue = queries::get_issue(&conn, id).unwrap();
assert_eq!(issue.target_date.as_deref(), Some("2026-06-15"));
assert_eq!(issue.start_date, None);
}
#[test]
fn update_issue_sets_start_date() {
let m = mcp();
seed_project(&m, "Test", "STD");
seed_issue(&m, "STD", "Original");
let result = m.update_issue(Parameters(UpdateIssueInput {
identifier: "STD-1".into(),
start_date: Some("2026-06-01".into()),
..Default::default()
}));
assert!(result.contains("STD-1"), "got: {result}");
let conn = m.db.read().unwrap();
let id = queries::resolve_identifier(&conn, "STD-1").unwrap();
let issue = queries::get_issue(&conn, id).unwrap();
assert_eq!(issue.start_date.as_deref(), Some("2026-06-01"));
}
#[test]
fn update_issue_omitting_dates_leaves_them_unchanged() {
let m = mcp();
seed_project(&m, "Test", "UNC");
m.create_issue(Parameters(CreateIssueInput {
project: "UNC".into(),
title: "Prescheduled".into(),
start_date: Some("2026-01-01".into()),
target_date: Some("2026-02-01".into()),
..Default::default()
}));
m.update_issue(Parameters(UpdateIssueInput {
identifier: "UNC-1".into(),
title: Some("Renamed".into()),
..Default::default()
}));
let conn = m.db.read().unwrap();
let id = queries::resolve_identifier(&conn, "UNC-1").unwrap();
let issue = queries::get_issue(&conn, id).unwrap();
assert_eq!(issue.title, "Renamed");
assert_eq!(issue.start_date.as_deref(), Some("2026-01-01"));
assert_eq!(issue.target_date.as_deref(), Some("2026-02-01"));
}
#[test]
fn bulk_update_sets_status_on_module_matches_only() {
let m = mcp();
seed_project(&m, "Bulk", "BLK");
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "module".into(),
action: "create".into(),
project: Some("BLK".into()),
name: Some("Backend".into()),
identifier: None,
current_name: None,
description: None,
status: None,
color: None,
..Default::default()
}));
for title in ["In-module A", "In-module B"] {
m.create_issue(Parameters(CreateIssueInput {
project: "BLK".into(),
title: title.into(),
status: Some("active".into()),
priority: None,
description: None,
module: Some("Backend".into()),
labels: None,
..Default::default()
}));
}
m.create_issue(Parameters(CreateIssueInput {
project: "BLK".into(),
title: "Loose one".into(),
status: Some("active".into()),
priority: None,
description: None,
module: None,
labels: None,
..Default::default()
}));
let result = m.bulk_update(Parameters(BulkUpdateInput {
project: "BLK".into(),
filter_status: Some("active".into()),
filter_module: Some("Backend".into()),
set_status: Some("done".into()),
..Default::default()
}));
assert_eq!(result, "Updated 2 issue(s)", "got: {result}");
for id in ["BLK-1", "BLK-2"] {
let got = m.get_issue(Parameters(GetIssueInput {
identifier: id.into(),
..Default::default()
}));
assert!(got.contains("Status: done"), "{id} not done: {got}");
}
let loose = m.get_issue(Parameters(GetIssueInput {
identifier: "BLK-3".into(),
..Default::default()
}));
assert!(loose.contains("Status: active"), "loose changed: {loose}");
}
#[test]
fn bulk_update_emits_issue_updates() {
let (m, mut rx) = mcp_with_realtime();
seed_project(&m, "Bulk Events", "BLE");
seed_issue(&m, "BLE", "One");
seed_issue(&m, "BLE", "Two");
let project_id = project_id_for(&m, "BLE");
let first_issue_id = issue_id_for(&m, "BLE-1");
let second_issue_id = issue_id_for(&m, "BLE-2");
drain_realtime(&mut rx);
let result = m.bulk_update(Parameters(BulkUpdateInput {
project: "BLE".into(),
set_status: Some("done".into()),
..Default::default()
}));
assert_eq!(result, "Updated 2 issue(s)", "got: {result}");
assert_eq!(
realtime_events(&mut rx),
vec![
crate::realtime::RealtimeEvent::IssueUpdated {
project_id,
issue_id: first_issue_id,
},
crate::realtime::RealtimeEvent::IssueUpdated {
project_id,
issue_id: second_issue_id,
},
]
);
}
#[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-01".into(),
project: None,
}));
assert_eq!(result, "Deleted issue DEL-1");
let get = m.get_issue(Parameters(GetIssueInput {
identifier: "DEL-1".into(),
..Default::default()
}));
assert!(get.starts_with("Error"), "got: {get}");
}
#[tokio::test]
async fn issue_delete_keeps_the_deleted_resource_reference_plain() {
let m = mcp();
seed_project(&m, "Test", "DEL");
seed_issue(&m, "DEL", "Doomed");
let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap();
let result = crate::mcp::with_request_context(None, false, Some(context), || async {
m.delete(Parameters(DeleteInput {
resource_type: "issue".into(),
identifier: "DEL-01".into(),
project: None,
}))
})
.await;
assert_eq!(result, "Deleted issue DEL-1");
}
#[test]
fn get_nonexistent_issue_errors() {
let m = mcp();
let result = m.get_issue(Parameters(GetIssueInput {
identifier: "NOPE-999".into(),
..Default::default()
}));
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,
..Default::default()
}));
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,
..Default::default()
}));
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_reads_link_context_once() {
let m = mcp();
seed_project(&m, "Context", "CTX");
seed_issue(&m, "CTX", "First");
seed_issue(&m, "CTX", "Second");
crate::mcp::reset_issue_link_context_reads();
let result = m.list_issues(Parameters(ListIssuesInput {
project: "CTX".into(),
..Default::default()
}));
assert!(result.contains("2 issues"), "got: {result}");
assert_eq!(crate::mcp::issue_link_context_reads(), 1);
}
#[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();
seed_project(&m, "Alpha", "AAA");
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-01".into(),
target: "LNK-02".into(),
relation_type: "blocks".into(),
}));
assert_eq!(result, "LNK-1 blocks LNK-2");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "LNK-1".into(),
..Default::default()
}));
assert!(detail.contains("Blocks: LNK-2 (backlog)"), "got: {detail}");
let result = m.unlink_issues(Parameters(UnlinkIssuesInput {
source: "LNK-01".into(),
target: "LNK-02".into(),
}));
assert_eq!(result, "Unlinked LNK-1 and LNK-2");
}
#[test]
fn get_issue_relations_carry_status() {
let m = mcp();
seed_project(&m, "Rel", "REL");
seed_issue(&m, "REL", "target"); seed_issue(&m, "REL", "blocker-a"); seed_issue(&m, "REL", "blocker-b");
m.link_issues(Parameters(LinkIssuesInput {
source: "REL-2".into(),
target: "REL-1".into(),
relation_type: "blocks".into(),
}));
m.link_issues(Parameters(LinkIssuesInput {
source: "REL-3".into(),
target: "REL-1".into(),
relation_type: "blocks".into(),
}));
m.update_issue(Parameters(UpdateIssueInput {
identifier: "REL-2".into(),
status: Some("done".into()),
..Default::default()
}));
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "REL-1".into(),
..Default::default()
}));
assert!(detail.contains("REL-2 (done)"), "got: {detail}");
assert!(detail.contains("REL-3 (backlog)"), "got: {detail}");
assert!(detail.contains("Blocked by:"), "got: {detail}");
}
#[test]
fn list_issues_blocked_filter_surfaces_blocked_by() {
let m = mcp();
seed_project(&m, "Test", "BLK");
seed_issue(&m, "BLK", "Blocker"); seed_issue(&m, "BLK", "Blocked"); m.link_issues(Parameters(LinkIssuesInput {
source: "BLK-1".into(),
target: "BLK-2".into(),
relation_type: "blocks".into(),
}));
let result = m.list_issues(Parameters(ListIssuesInput {
project: "BLK".into(),
blocked: Some(true),
..Default::default()
}));
assert!(result.contains("1 issues"), "got: {result}");
assert!(result.contains("Blocked"), "got: {result}");
assert!(result.contains("blocked_by:BLK-1"), "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,
..Default::default()
}));
m.create_issue(Parameters(CreateIssueInput {
project: "BRD".into(),
title: "B".into(),
status: Some("active".into()),
description: None,
priority: None,
module: None,
labels: None,
..Default::default()
}));
let result = m.get_board(Parameters(GetBoardInput {
project: "BRD".into(),
group_by: None,
..Default::default()
}));
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,
..Default::default()
}));
}
let result = m.get_board(Parameters(GetBoardInput {
project: "BRO".into(),
group_by: None,
include_closed: Some(true),
..Default::default()
}));
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,
..Default::default()
}));
}
let result = m.get_board(Parameters(GetBoardInput {
project: "BRP".into(),
group_by: Some("priority".into()),
..Default::default()
}));
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}");
}
fn seed_board_mix(m: &LificMcp, ident: &str) {
seed_project(m, "Closed Board", ident);
for (title, status) in [
("open-todo", "todo"),
("open-active", "active"),
("shipped-1", "done"),
("shipped-2", "done"),
("scrapped", "cancelled"),
] {
m.create_issue(Parameters(CreateIssueInput {
project: ident.into(),
title: title.into(),
status: Some(status.into()),
..Default::default()
}));
}
}
#[test]
fn board_default_omits_closed_contents_but_shows_counts() {
let m = mcp();
seed_board_mix(&m, "BCA");
let result = m.get_board(Parameters(GetBoardInput {
project: "BCA".into(),
..Default::default()
}));
assert!(
result.contains("── done (2) ── [omitted — pass include_closed=true]"),
"got: {result}"
);
assert!(
result.contains("── cancelled (1) ── [omitted — pass include_closed=true]"),
"got: {result}"
);
assert!(!result.contains("shipped-1"), "got: {result}");
assert!(!result.contains("scrapped"), "got: {result}");
assert!(result.contains("open-todo"), "got: {result}");
assert!(result.contains("open-active"), "got: {result}");
}
#[test]
fn board_include_closed_shows_closed_issues() {
let m = mcp();
seed_board_mix(&m, "BCB");
let result = m.get_board(Parameters(GetBoardInput {
project: "BCB".into(),
include_closed: Some(true),
..Default::default()
}));
assert!(!result.contains("[omitted"), "got: {result}");
assert!(result.contains("shipped-1"), "got: {result}");
assert!(result.contains("shipped-2"), "got: {result}");
assert!(result.contains("scrapped"), "got: {result}");
}
#[test]
fn board_priority_grouping_excludes_closed_with_trailing_note() {
let m = mcp();
seed_board_mix(&m, "BCC");
let result = m.get_board(Parameters(GetBoardInput {
project: "BCC".into(),
group_by: Some("priority".into()),
..Default::default()
}));
assert!(!result.contains("shipped-1"), "got: {result}");
assert!(!result.contains("scrapped"), "got: {result}");
assert!(
result.contains("(3 closed issues omitted — pass include_closed=true)"),
"got: {result}"
);
assert!(
!result.contains("[omitted — pass include_closed=true]"),
"got: {result}"
);
}
#[test]
fn board_max_per_column_truncates_with_tail() {
let m = mcp();
seed_project(&m, "Capped Board", "BCD");
for i in 0..4 {
m.create_issue(Parameters(CreateIssueInput {
project: "BCD".into(),
title: format!("todo-{i}"),
status: Some("todo".into()),
..Default::default()
}));
}
let result = m.get_board(Parameters(GetBoardInput {
project: "BCD".into(),
max_per_column: Some(2),
..Default::default()
}));
assert!(result.contains("── todo (4) ──"), "got: {result}");
assert!(
result.contains("… +2 more (use list_issues)"),
"got: {result}"
);
let rendered = result.matches("| todo | ").count();
assert_eq!(rendered, 2, "got: {result}");
}
#[test]
fn board_empty_done_group_produces_no_stub() {
let m = mcp();
seed_project(&m, "No Done", "BCE");
m.create_issue(Parameters(CreateIssueInput {
project: "BCE".into(),
title: "just-todo".into(),
status: Some("todo".into()),
..Default::default()
}));
let result = m.get_board(Parameters(GetBoardInput {
project: "BCE".into(),
..Default::default()
}));
assert!(!result.contains("done"), "got: {result}");
assert!(!result.contains("cancelled"), "got: {result}");
assert!(!result.contains("[omitted"), "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,
pinned: 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-01".into(),
project: None,
}));
assert_eq!(result, "Deleted page PGD-DOC-1");
}
#[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_formats_issue_page_and_comment_results_distinctly() {
let m = mcp();
let _guard = seed_user(&m);
seed_project(&m, "Formatting", "FMT");
seed_issue(&m, "FMT", "Issue mixedformatneedle");
let page = m.create_page(Parameters(CreatePageInput {
project: Some("FMT".into()),
title: "Page mixedformatneedle".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
assert!(page.starts_with("Created FMT-DOC-1"), "got: {page}");
let comment = m.add_comment(Parameters(AddCommentInput {
identifier: "FMT-1".into(),
content: "Comment mixedformatneedle".into(),
}));
assert!(comment.starts_with("Comment #"), "got: {comment}");
let result = m.search(Parameters(SearchInput {
query: "mixedformatneedle".into(),
..Default::default()
}));
assert!(result.starts_with("3 results:\n"), "got: {result}");
assert!(
result.contains("- [issue] FMT-1 Issue mixedformatneedle "),
"got: {result}"
);
assert!(
result.contains("- [page] FMT-DOC-1 Page mixedformatneedle "),
"got: {result}"
);
assert!(result.contains("- [comment] on FMT-1 "), "got: {result}");
}
#[tokio::test]
async fn search_renders_a_comment_as_one_direct_link() {
let m = mcp();
seed_project(&m, "Test", "SRC");
seed_issue(&m, "SRC", "Search comments");
let author = make_user(&m, "author", false);
let auth_user = models::AuthUser {
id: author.id,
username: author.username,
display_name: author.display_name,
is_admin: author.is_admin,
};
let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap();
let result =
crate::mcp::with_request_context(Some(auth_user), false, Some(context), || async {
m.add_comment(Parameters(AddCommentInput {
identifier: "SRC-1".into(),
content: "Unique comment needle".into(),
}));
m.search(Parameters(SearchInput {
query: "needle".into(),
result_type: Some("comment".into()),
..Default::default()
}))
})
.await;
assert!(
result.contains(
"- [comment #1](https://tracker.example/SRC/issues/SRC-1#comment-1) on [SRC-1](https://tracker.example/SRC/issues/SRC-1)"
),
"got: {result}"
);
assert!(!result.contains("[[comment"), "got: {result}");
}
#[test]
fn mcp_search_literal_mode_finds_punctuation_needle() {
let m = mcp();
seed_project(&m, "Test", "SRC");
seed_issue(&m, "SRC", "wire up core:sodom pipeline");
let result = m.search(Parameters(SearchInput {
query: "core:sodom".into(),
mode: Some("literal".into()),
..Default::default()
}));
assert!(result.contains("1 results"), "got: {result}");
assert!(result.contains("SRC-1"), "got: {result}");
assert!(result.contains("**core:sodom**"), "got: {result}");
}
#[test]
fn mcp_search_invalid_mode_errors() {
let m = mcp();
seed_project(&m, "Test", "SRC");
let result = m.search(Parameters(SearchInput {
query: "anything".into(),
mode: Some("regex".into()),
..Default::default()
}));
assert!(result.contains("invalid mode"), "got: {result}");
}
#[test]
fn search_no_results() {
let m = mcp();
seed_project(&m, "Alpha", "AAA");
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_projects_shows_agent_stats_and_recent_work_first() {
let m = mcp();
seed_project(&m, "Stale", "STA");
seed_project(&m, "Recent", "REC");
seed_project(&m, "Empty", "EMP");
let stale_project_id = project_id_for(&m, "STA");
m.write(|conn| {
conn.execute(
"INSERT INTO issues (project_id, sequence, title, status, updated_at)
VALUES (?1, 1, 'Old work', 'todo', '2000-01-01 00:00:00')",
rusqlite::params![stale_project_id],
)?;
Ok(())
})
.unwrap();
seed_issue(&m, "REC", "Current work");
let created = m.create_plan(Parameters(CreatePlanInput {
project: "REC".into(),
title: "Current plan".into(),
anchor_issue: None,
steps: None,
}));
assert!(created.starts_with("Created REC-PLAN-1"), "got: {created}");
let result = m.list_resources(Parameters(ListResourcesInput {
resource_type: "project".into(),
..Default::default()
}));
let recent = result
.lines()
.find(|line| line.starts_with("- REC | Recent"))
.expect("recent project line");
assert!(
recent.starts_with("- REC | Recent (1 workable, 1 active plan, last activity "),
"got: {recent}"
);
assert!(recent.ends_with(" ago)"), "got: {recent}");
assert_eq!(
result
.lines()
.find(|line| line.starts_with("- EMP | Empty")),
Some("- EMP | Empty"),
"fresh empty project must have no stats suffix: {result}"
);
assert!(
result.find("- REC | Recent").unwrap() < result.find("- STA | Stale").unwrap(),
"recent work must sort before stale work: {result}"
);
}
#[test]
fn nudge_list_resources_project_on_empty_db() {
let m = mcp();
let result = m.list_resources(Parameters(ListResourcesInput {
resource_type: "project".into(),
..Default::default()
}));
assert_eq!(result, NO_PROJECTS_NUDGE, "got: {result}");
}
#[test]
fn nudge_list_issues_on_empty_db() {
let m = mcp();
let result = m.list_issues(Parameters(ListIssuesInput {
project: "ANY".into(),
..Default::default()
}));
assert_eq!(result, NO_PROJECTS_NUDGE, "got: {result}");
}
#[test]
fn nudge_search_on_empty_db() {
let m = mcp();
let result = m.search(Parameters(SearchInput {
query: "anything".into(),
..Default::default()
}));
assert_eq!(result, NO_PROJECTS_NUDGE, "got: {result}");
}
#[test]
fn nudge_get_board_on_empty_db() {
let m = mcp();
let result = m.get_board(Parameters(GetBoardInput {
project: "ANY".into(),
..Default::default()
}));
assert_eq!(result, NO_PROJECTS_NUDGE, "got: {result}");
}
#[test]
fn no_nudge_once_a_project_exists() {
let m = mcp();
seed_project(&m, "Alpha", "AAA");
let listed = m.list_resources(Parameters(ListResourcesInput {
resource_type: "project".into(),
..Default::default()
}));
assert!(!listed.contains(NO_PROJECTS_NUDGE), "got: {listed}");
assert!(listed.contains("1 projects"), "got: {listed}");
let issues = m.list_issues(Parameters(ListIssuesInput {
project: "AAA".into(),
..Default::default()
}));
assert!(!issues.contains(NO_PROJECTS_NUDGE), "got: {issues}");
let searched = m.search(Parameters(SearchInput {
query: "anything".into(),
..Default::default()
}));
assert!(!searched.contains(NO_PROJECTS_NUDGE), "got: {searched}");
let board = m.get_board(Parameters(GetBoardInput {
project: "AAA".into(),
..Default::default()
}));
assert!(!board.contains(NO_PROJECTS_NUDGE), "got: {board}");
}
#[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_module_reports_the_canonical_module_name() {
let m = mcp();
seed_project(&m, "Modules", "MOD");
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "module".into(),
action: "create".into(),
project: Some("MOD".into()),
name: Some("Backend".into()),
..Default::default()
}));
let result = m.delete(Parameters(DeleteInput {
resource_type: "module".into(),
identifier: "backend".into(),
project: Some("MOD".into()),
}));
assert_eq!(result, "Deleted module Backend");
}
#[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,
emoji: 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,
emoji: 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,
emoji: 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,
emoji: None,
}));
assert!(result.contains("Documentation"), "got: {result}");
}
#[test]
fn manage_resource_structure_mutations_emit_project_updates() {
let (m, mut rx) = mcp_with_realtime();
seed_project(&m, "Structure Events", "STR");
let project_id = project_id_for(&m, "STR");
drain_realtime(&mut rx);
for (resource_type, name, updated_name) in [
("module", "Core", "Core v2"),
("label", "bug", "defect"),
("folder", "Docs", "Documentation"),
] {
let created = m.manage_resource(Parameters(ManageResourceInput {
resource_type: resource_type.into(),
action: "create".into(),
project: Some("STR".into()),
name: Some(name.into()),
..Default::default()
}));
assert!(!created.starts_with("Error"), "got: {created}");
assert_eq!(
realtime_events(&mut rx),
vec![crate::realtime::RealtimeEvent::ProjectUpdated { project_id }]
);
let updated = m.manage_resource(Parameters(ManageResourceInput {
resource_type: resource_type.into(),
action: "update".into(),
project: Some("STR".into()),
current_name: Some(name.into()),
name: Some(updated_name.into()),
..Default::default()
}));
assert!(!updated.starts_with("Error"), "got: {updated}");
assert_eq!(
realtime_events(&mut rx),
vec![crate::realtime::RealtimeEvent::ProjectUpdated { project_id }]
);
let deleted = m.delete(Parameters(DeleteInput {
resource_type: resource_type.into(),
identifier: updated_name.into(),
project: Some("STR".into()),
}));
assert!(!deleted.starts_with("Error"), "got: {deleted}");
assert_eq!(
realtime_events(&mut rx),
vec![crate::realtime::RealtimeEvent::ProjectUpdated { project_id }]
);
}
}
struct FailingWriter;
impl fmt::Write for FailingWriter {
fn write_str(&mut self, _: &str) -> fmt::Result {
Err(fmt::Error)
}
}
#[test]
fn issue_relations_propagate_formatter_failures() {
let identifiers = vec!["T-2".into()];
let relations = IssueRelations {
label: " blocks:",
identifiers: &identifiers,
context: None,
};
assert!(write!(FailingWriter, "{relations}").is_err());
}
#[test]
fn write_issue_formats_relations_with_one_context_read() {
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(),
source: None,
labels: vec!["bug".into()],
blocks: vec!["T-2".into()],
blocked_by: vec![],
relates_to: vec![],
duplicates: vec!["T-3".into()],
duplicated_by: vec!["T-4".into()],
};
crate::mcp::reset_issue_link_context_reads();
let context = current_issue_link_context();
let s = render_response(|output| {
write!(
output,
"{}",
IssueLine {
issue: &issue,
context: context.as_deref(),
}
)
});
assert!(s.contains("[bug]"), "got: {s}");
assert!(s.contains("blocks:T-2"), "got: {s}");
assert!(s.contains("duplicates:T-3"), "got: {s}");
assert!(s.contains("duplicated_by:T-4"), "got: {s}");
assert_eq!(crate::mcp::issue_link_context_reads(), 1);
}
fn seed_user(mcp: &LificMcp) -> tokio::sync::MutexGuard<'static, ()> {
let guard = crate::mcp::MCP_HANDLER_LOCK.blocking_lock();
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();
drop(conn);
*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,
});
guard
}
#[test]
fn add_and_list_comments() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Test issue");
let _guard = 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 add_comment_records_mentions() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Mention issue");
let _guard = seed_user(&m);
let ada_id = {
let conn = m.db.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&models::CreateUser {
username: "ada".into(),
email: "ada@test.com".into(),
password: "testpassword1".into(),
display_name: Some("Ada".into()),
is_admin: false,
is_bot: false,
},
)
.unwrap()
.id
};
let result = m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "cc @ada and @ghost".into(),
}));
assert!(result.starts_with("Comment #"), "got: {result}");
let comment_id: i64 = result
.trim_start_matches("Comment #")
.split_whitespace()
.next()
.unwrap()
.parse()
.unwrap();
let conn = m.db.read().unwrap();
let mentions =
crate::db::queries::comments::list_mention_user_ids(&conn, comment_id).unwrap();
assert_eq!(mentions, vec![ada_id], "only the real user @ada resolves");
}
#[test]
fn get_issue_includes_comments() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Commented issue");
let _guard = 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(),
..Default::default()
}));
assert!(result.contains("Comments (1)"), "got: {result}");
assert!(result.contains("Visible in get_issue"), "got: {result}");
}
fn seed_comment_trail(m: &LificMcp, n: usize) {
for i in 1..=n {
m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: format!("comment number {i}"),
}));
}
}
#[test]
fn get_issue_recent_truncates_over_three_comments() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Chatty issue");
let _guard = seed_user(&m);
seed_comment_trail(&m, 9);
let result = m.get_issue(Parameters(GetIssueInput {
identifier: "PRJ-1".into(),
..Default::default()
}));
assert!(
result.contains("--- Comments (9, showing last 3 — use list_comments) ---"),
"got: {result}"
);
assert!(result.contains("comment number 7"), "got: {result}");
assert!(result.contains("comment number 8"), "got: {result}");
assert!(result.contains("comment number 9"), "got: {result}");
assert!(!result.contains("comment number 6"), "got: {result}");
assert!(!result.contains("comment number 1 "), "got: {result}");
}
#[test]
fn get_issue_recent_unchanged_at_three_or_fewer() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Few comments");
let _guard = seed_user(&m);
seed_comment_trail(&m, 3);
let result = m.get_issue(Parameters(GetIssueInput {
identifier: "PRJ-1".into(),
..Default::default()
}));
assert!(result.contains("--- Comments (3) ---"), "got: {result}");
assert!(!result.contains("showing last 3"), "got: {result}");
assert!(result.contains("comment number 1"), "got: {result}");
}
#[test]
fn get_issue_all_shows_every_comment() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Full history");
let _guard = seed_user(&m);
seed_comment_trail(&m, 5);
let result = m.get_issue(Parameters(GetIssueInput {
identifier: "PRJ-1".into(),
include_comments: Some("all".into()),
}));
assert!(result.contains("--- Comments (5) ---"), "got: {result}");
for i in 1..=5 {
assert!(
result.contains(&format!("comment number {i}")),
"got: {result}"
);
}
}
#[test]
fn get_issue_none_emits_stub_only() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Suppressed");
let _guard = seed_user(&m);
seed_comment_trail(&m, 9);
let result = m.get_issue(Parameters(GetIssueInput {
identifier: "PRJ-1".into(),
include_comments: Some("none".into()),
}));
assert!(
result.contains("--- Comments (9, omitted — use list_comments) ---"),
"got: {result}"
);
assert!(!result.contains("comment number"), "got: {result}");
}
#[test]
fn get_issue_none_with_zero_comments_shows_nothing() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Quiet");
let result = m.get_issue(Parameters(GetIssueInput {
identifier: "PRJ-1".into(),
include_comments: Some("none".into()),
}));
assert!(!result.contains("Comments"), "got: {result}");
}
#[test]
fn get_issue_invalid_include_comments_errors() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Bad mode");
let result = m.get_issue(Parameters(GetIssueInput {
identifier: "PRJ-1".into(),
include_comments: Some("last5".into()),
}));
assert_eq!(
result,
"Error: invalid include_comments 'last5'. Use recent, all, or none."
);
}
#[test]
fn list_comments_limit_paginates_with_hint() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Limited");
let _guard = seed_user(&m);
seed_comment_trail(&m, 9);
let result = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
limit: Some(3),
..Default::default()
}));
assert!(
result.starts_with("Showing 3 oldest of 9 comment(s) on PRJ-1:"),
"got: {result}"
);
assert!(
result.contains("call again with the same author/order/limit and offset=3"),
"got: {result}"
);
assert!(result.contains("comment number 1"), "got: {result}");
assert!(result.contains("comment number 3"), "got: {result}");
assert!(!result.contains("comment number 4"), "got: {result}");
}
#[test]
fn list_comments_limit_desc_returns_newest_first() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Newest N");
let _guard = seed_user(&m);
seed_comment_trail(&m, 9);
let result = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
order: Some("desc".into()),
limit: Some(2),
..Default::default()
}));
assert!(
result.starts_with("Showing 2 most recent of 9 comment(s) on PRJ-1:"),
"got: {result}"
);
assert!(result.contains("comment number 9"), "got: {result}");
assert!(result.contains("comment number 8"), "got: {result}");
assert!(!result.contains("comment number 1"), "got: {result}");
}
#[test]
fn list_comments_offset_returns_next_page() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Paged");
let _guard = seed_user(&m);
seed_comment_trail(&m, 5);
let result = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
limit: Some(2),
offset: Some(2),
..Default::default()
}));
assert!(result.contains("comment number 3"), "got: {result}");
assert!(result.contains("comment number 4"), "got: {result}");
assert!(!result.contains("comment number 2"), "got: {result}");
assert!(!result.contains("comment number 5"), "got: {result}");
assert!(
result.contains("call again with the same author/order/limit and offset=4"),
"got: {result}"
);
assert!(
result.starts_with("Showing comments 3-4 of 5 on PRJ-1 (oldest first):"),
"got: {result}"
);
}
#[test]
fn list_comments_no_limit_keeps_plain_header() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Unlimited");
let _guard = seed_user(&m);
seed_comment_trail(&m, 9);
let result = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
..Default::default()
}));
assert!(
result.starts_with("9 comment(s) on PRJ-1:"),
"got: {result}"
);
for n in 1..=9 {
assert!(
result.contains(&format!("comment number {n}")),
"got: {result}"
);
}
assert!(!result.contains("Showing"), "got: {result}");
assert!(!result.contains("more comment(s)"), "got: {result}");
}
#[test]
fn list_comments_offset_without_limit_returns_unbounded_remainder() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Offset remainder");
let _guard = seed_user(&m);
seed_comment_trail(&m, 5);
let result = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
offset: Some(2),
..Default::default()
}));
assert!(
result.starts_with("Showing comments 3-5 of 5 on PRJ-1 (oldest first):"),
"got: {result}"
);
for n in 3..=5 {
assert!(
result.contains(&format!("comment number {n}")),
"got: {result}"
);
}
assert!(!result.contains("comment number 2"), "got: {result}");
assert!(!result.contains("more comment(s)"), "got: {result}");
}
#[test]
fn list_comments_offset_past_end_reports_total() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Exhausted");
let _guard = seed_user(&m);
seed_comment_trail(&m, 3);
let result = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
limit: Some(2),
offset: Some(10),
..Default::default()
}));
assert!(result.contains("offset 10, 3 total"), "got: {result}");
assert!(!result.contains("No comments on"), "got: {result}");
}
#[test]
fn list_comments_with_zero_comments_reports_empty_thread() {
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_eq!(result, "No comments on PRJ-1.");
}
#[test]
fn add_comment_bad_identifier() {
let m = mcp();
let _guard = 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);
let _guard = crate::mcp::MCP_HANDLER_LOCK.blocking_lock();
*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,
}));
let _guard = seed_user(&m);
let result = m.add_comment(Parameters(AddCommentInput {
identifier: "PGC-DOC-01".into(),
content: "Comment on a page".into(),
}));
assert!(result.starts_with("Comment #"), "got: {result}");
assert!(result.contains("PGC-DOC-1"), "got: {result}");
assert!(!result.contains("PGC-DOC-01"), "got: {result}");
let listing = m.list_comments(Parameters(ListCommentsInput {
identifier: "PGC-DOC-01".into(),
..Default::default()
}));
assert!(
listing.starts_with("1 comment(s) on PGC-DOC-1:"),
"got: {listing}"
);
assert!(listing.contains("Comment on a page"), "got: {listing}");
}
#[test]
fn project_page_comment_mutations_emit_project_updates() {
let (m, mut rx) = mcp_with_realtime();
seed_project(&m, "Page Comments", "PCO");
let project_id = project_id_for(&m, "PCO");
m.create_page(Parameters(CreatePageInput {
project: Some("PCO".into()),
title: "Design".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
let _guard = seed_user(&m);
drain_realtime(&mut rx);
let added = m.add_comment(Parameters(AddCommentInput {
identifier: "PCO-DOC-1".into(),
content: "original".into(),
}));
let comment_id = comment_id_from(&added);
assert_eq!(
realtime_events(&mut rx),
vec![crate::realtime::RealtimeEvent::ProjectUpdated { project_id }]
);
let edited = m.edit_comment(Parameters(EditCommentInput {
comment_id,
content: "revised".into(),
}));
assert!(edited.starts_with("Comment #"), "got: {edited}");
assert_eq!(
realtime_events(&mut rx),
vec![crate::realtime::RealtimeEvent::ProjectUpdated { project_id }]
);
let deleted = m.delete_comment(Parameters(DeleteCommentInput { comment_id }));
assert!(deleted.starts_with("Comment #"), "got: {deleted}");
assert_eq!(
realtime_events(&mut rx),
vec![crate::realtime::RealtimeEvent::ProjectUpdated { project_id }]
);
}
#[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,
}));
let _guard = 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,
}));
let _guard = 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,
..Default::default()
}));
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(),
..Default::default()
}));
assert!(detail.contains("The quick red fox"), "got: {detail}");
assert!(!detail.contains("brown"), "got: {detail}");
}
#[test]
fn export_dispatches_on_identifier_shape() {
let m = mcp();
seed_project(&m, "Test", "EXP");
seed_issue_with_description(&m, "EXP", "Ship it", "issue body here");
let created = m.create_page(Parameters(CreatePageInput {
project: Some("EXP".into()),
title: "Design notes".into(),
content: Some("page body here".into()),
..Default::default()
}));
assert!(created.starts_with("Created"), "got: {created}");
let issue = m.export(Parameters(ExportInput {
identifier: "EXP-1".into(),
}));
assert!(issue.contains("issue body here"), "got: {issue}");
let page = m.export(Parameters(ExportInput {
identifier: "EXP-DOC-1".into(),
}));
assert!(page.contains("page body here"), "got: {page}");
let project = m.export(Parameters(ExportInput {
identifier: "EXP".into(),
}));
assert!(project.contains("exported file(s)"), "got: {project}");
let err = m.export(Parameters(ExportInput {
identifier: "NOPE-999".into(),
}));
assert!(
err.contains("not a known issue, page, or project"),
"got: {err}"
);
}
#[test]
fn create_and_edit_issue_preserves_literal_escapes_in_multiline_code() {
let m = mcp();
seed_project(&m, "Test", "ESC");
let description = "Example:\n```c\nprintf(\"\\n\");\n```\n";
let created = m.create_issue(Parameters(CreateIssueInput {
project: "ESC".into(),
title: "Preserve code escapes".into(),
description: Some(description.into()),
..Default::default()
}));
assert!(created.starts_with("Created"), "got: {created}");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "ESC-1".into(),
..Default::default()
}));
assert!(
detail.contains(description),
"get_issue mangled content: {detail}"
);
let exported = m.export(Parameters(ExportInput {
identifier: "ESC-1".into(),
}));
assert!(
exported.contains(description.trim_end()),
"export mangled content: {exported}"
);
let result = m.edit_issue(Parameters(EditIssueInput {
identifier: "ESC-1".into(),
old_string: "```c\nprintf(\"\\n\");\n```".into(),
new_string: "```c\nputs(\"hello\");\n```".into(),
field: None,
replace_all: None,
}));
assert!(result.starts_with("Edited"), "got: {result}");
let updated = m.get_issue(Parameters(GetIssueInput {
identifier: "ESC-1".into(),
..Default::default()
}));
assert!(updated.contains("puts(\"hello\");"), "got: {updated}");
assert!(
!updated.contains("printf(\"\\n\");"),
"literal escape was not replaced: {updated}"
);
}
#[test]
fn edit_issue_emits_issue_update() {
let (m, mut rx) = mcp_with_realtime();
seed_project(&m, "Edit Events", "EDE");
seed_issue_with_description(&m, "EDE", "T", "hello world");
let project_id = project_id_for(&m, "EDE");
let issue_id = issue_id_for(&m, "EDE-1");
drain_realtime(&mut rx);
let result = m.edit_issue(Parameters(EditIssueInput {
identifier: "EDE-1".into(),
old_string: "world".into(),
new_string: "there".into(),
field: None,
replace_all: None,
}));
assert!(result.starts_with("Edited"), "got: {result}");
assert_eq!(
realtime_events(&mut rx),
vec![crate::realtime::RealtimeEvent::IssueUpdated {
project_id,
issue_id,
}]
);
}
#[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(),
..Default::default()
}));
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(),
..Default::default()
}));
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(),
..Default::default()
}));
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,
..Default::default()
}));
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(),
..Default::default()
}));
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 project_scoped_page_mutations_emit_project_updates() {
let (m, mut rx) = mcp_with_realtime();
seed_project(&m, "Page Events", "PGE");
let project_id = project_id_for(&m, "PGE");
drain_realtime(&mut rx);
let created = m.create_page(Parameters(CreatePageInput {
project: Some("PGE".into()),
title: "Design".into(),
content: Some("draft".into()),
folder: None,
status: None,
labels: None,
}));
assert!(created.starts_with("Created"), "got: {created}");
assert_eq!(
realtime_events(&mut rx),
vec![crate::realtime::RealtimeEvent::ProjectUpdated { project_id }]
);
let updated = m.update_page(Parameters(UpdatePageInput {
identifier: "PGE-DOC-1".into(),
title: Some("Design v2".into()),
..Default::default()
}));
assert!(updated.starts_with("Updated"), "got: {updated}");
assert_eq!(
realtime_events(&mut rx),
vec![crate::realtime::RealtimeEvent::ProjectUpdated { project_id }]
);
let edited = m.edit_page(Parameters(EditPageInput {
identifier: "PGE-DOC-1".into(),
old_string: "draft".into(),
new_string: "published".into(),
field: None,
replace_all: None,
}));
assert!(edited.starts_with("Edited"), "got: {edited}");
assert_eq!(
realtime_events(&mut rx),
vec![crate::realtime::RealtimeEvent::ProjectUpdated { project_id }]
);
}
#[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,
emoji: 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,
emoji: 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,
pinned: 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_update_issue_clears_module_with_empty_string() {
let m = mcp();
seed_project(&m, "Clear Module", "CLM");
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "module".into(),
action: "create".into(),
project: Some("CLM".into()),
name: Some("Core".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: None,
}));
seed_issue(&m, "CLM", "Task");
let set = m.update_issue(Parameters(UpdateIssueInput {
identifier: "CLM-1".into(),
title: None,
description: None,
status: None,
priority: None,
module: Some("Core".into()),
labels: None,
..Default::default()
}));
assert!(!set.starts_with("Error"), "set failed: {set}");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "CLM-1".into(),
..Default::default()
}));
assert!(detail.contains("Module: Core"), "got: {detail}");
let cleared = m.update_issue(Parameters(UpdateIssueInput {
identifier: "CLM-1".into(),
title: None,
description: None,
status: None,
priority: None,
module: Some(String::new()),
labels: None,
..Default::default()
}));
assert!(!cleared.starts_with("Error"), "clear failed: {cleared}");
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "CLM-1".into(),
..Default::default()
}));
assert!(detail.contains("Module: none"), "got: {detail}");
let conn = m.db.read().unwrap();
let id = queries::resolve_identifier(&conn, "CLM-1").unwrap();
assert_eq!(queries::get_issue(&conn, id).unwrap().module_id, None);
}
#[test]
fn mcp_update_page_clears_folder_with_empty_string() {
let m = mcp();
seed_project(&m, "Clear Folder", "CLF");
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "folder".into(),
action: "create".into(),
project: Some("CLF".into()),
name: Some("Docs".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: None,
}));
m.create_page(Parameters(CreatePageInput {
project: Some("CLF".into()),
title: "Spec".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
let moved = m.update_page(Parameters(UpdatePageInput {
identifier: "CLF-DOC-1".into(),
title: None,
content: None,
folder: Some("Docs".into()),
status: None,
pinned: None,
labels: None,
}));
assert!(!moved.starts_with("Error"), "move failed: {moved}");
{
let conn = m.db.read().unwrap();
let id = queries::resolve_page_identifier(&conn, "CLF-DOC-1").unwrap();
assert!(queries::get_page(&conn, id).unwrap().folder_id.is_some());
}
let rooted = m.update_page(Parameters(UpdatePageInput {
identifier: "CLF-DOC-1".into(),
title: None,
content: None,
folder: Some(String::new()),
status: None,
pinned: None,
labels: None,
}));
assert!(!rooted.starts_with("Error"), "root failed: {rooted}");
let conn = m.db.read().unwrap();
let id = queries::resolve_page_identifier(&conn, "CLF-DOC-1").unwrap();
assert_eq!(queries::get_page(&conn, id).unwrap().folder_id, None);
}
#[test]
fn mcp_manage_resource_sets_then_clears_project_emoji() {
let m = mcp();
seed_project(&m, "Emoji Project", "EMP");
let set = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "update".into(),
project: Some("EMP".into()),
name: None,
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: Some("lucide:Rocket".into()),
}));
assert!(!set.starts_with("Error"), "set failed: {set}");
{
let conn = m.db.read().unwrap();
let pid = queries::resolve_project_identifier(&conn, "EMP").unwrap();
assert_eq!(
queries::get_project(&conn, pid).unwrap().emoji.as_deref(),
Some("lucide:Rocket")
);
}
let cleared = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "update".into(),
project: Some("EMP".into()),
name: None,
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: Some(String::new()),
}));
assert!(!cleared.starts_with("Error"), "clear failed: {cleared}");
let conn = m.db.read().unwrap();
let pid = queries::resolve_project_identifier(&conn, "EMP").unwrap();
assert_eq!(queries::get_project(&conn, pid).unwrap().emoji, None);
}
#[test]
fn mcp_manage_resource_sets_module_emoji() {
let m = mcp();
seed_project(&m, "Module Emoji", "MEM");
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "module".into(),
action: "create".into(),
project: Some("MEM".into()),
name: Some("Core".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: None,
}));
let set = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "module".into(),
action: "update".into(),
project: Some("MEM".into()),
name: None,
identifier: None,
description: None,
current_name: Some("Core".into()),
status: None,
color: None,
emoji: Some("lucide:Boxes".into()),
}));
assert!(!set.starts_with("Error"), "set failed: {set}");
let conn = m.db.read().unwrap();
let pid = queries::resolve_project_identifier(&conn, "MEM").unwrap();
let mid = queries::resolve_module_name(&conn, pid, "Core").unwrap();
assert_eq!(
queries::get_module(&conn, mid).unwrap().emoji.as_deref(),
Some("lucide:Boxes")
);
}
#[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,
emoji: 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,
emoji: 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_resources_pages_respects_limit() {
let m = mcp();
seed_project(&m, "Pag", "PAG");
for title in ["P1", "P2", "P3", "P4", "P5"] {
m.create_page(Parameters(CreatePageInput {
project: Some("PAG".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("PAG".into()),
limit: Some(2),
..Default::default()
}));
assert!(listing.starts_with("2 pages:"), "got: {listing}");
assert_eq!(
listing.matches("| draft |").count(),
2,
"expected exactly 2 page lines, got: {listing}"
);
assert!(
listing.contains("call again with offset=2"),
"got: {listing}"
);
}
#[test]
fn mcp_list_resources_pages_offset_pages_correctly() {
let m = mcp();
seed_project(&m, "Off", "OFF");
for title in ["A", "B", "C", "D"] {
m.create_page(Parameters(CreatePageInput {
project: Some("OFF".into()),
title: title.into(),
content: None,
folder: None,
status: None,
labels: None,
}));
}
let page2 = m.list_resources(Parameters(ListResourcesInput {
resource_type: "page".into(),
project: Some("OFF".into()),
order_by: Some("title".into()),
order: Some("asc".into()),
limit: Some(2),
offset: Some(2),
..Default::default()
}));
assert!(!page2.contains("| A "), "got: {page2}");
assert!(!page2.contains("| B "), "got: {page2}");
assert!(page2.contains("| C "), "got: {page2}");
assert!(page2.contains("| D "), "got: {page2}");
}
#[test]
fn mcp_list_resources_pages_hint_absent_on_last_page() {
let m = mcp();
seed_project(&m, "Last", "LST");
for title in ["X", "Y", "Z"] {
m.create_page(Parameters(CreatePageInput {
project: Some("LST".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("LST".into()),
limit: Some(3),
..Default::default()
}));
assert!(listing.starts_with("3 pages:"), "got: {listing}");
assert!(
!listing.contains("more results available"),
"hint should be absent when no more pages remain, got: {listing}"
);
}
#[test]
fn mcp_list_comments_author_filter() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Authored");
let _guard = 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,
limit: None,
offset: None,
}));
assert!(mine.contains("Mine"), "got: {mine}");
let ghost = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
author: Some("ghost".into()),
order: None,
limit: None,
offset: 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");
let _guard = 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()),
limit: None,
offset: None,
}));
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()),
limit: None,
offset: None,
}));
assert!(bad.contains("Error"), "got: {bad}");
}
fn act_as(user: &models::User) -> tokio::sync::MutexGuard<'static, ()> {
let guard = crate::mcp::MCP_HANDLER_LOCK.blocking_lock();
*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.clone(),
is_admin: user.is_admin,
});
guard
}
fn make_user(m: &LificMcp, username: &str, is_admin: bool) -> models::User {
let conn = m.db.write().unwrap();
let u = queries::users::create_user(
&conn,
&models::CreateUser {
username: username.into(),
email: format!("{username}@test.com"),
password: "testpassword1".into(),
display_name: Some(username.into()),
is_admin,
is_bot: false,
},
)
.unwrap();
drop(conn);
u
}
fn comment_id_from(result: &str) -> i64 {
result
.split('#')
.nth(1)
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or_else(|| panic!("no comment id in: {result}"))
}
#[test]
fn edit_comment_author_can_edit_own() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Editable");
let author = make_user(&m, "author", false);
let _guard = act_as(&author);
let added = m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "original".into(),
}));
let cid = comment_id_from(&added);
let edited = m.edit_comment(Parameters(EditCommentInput {
comment_id: cid,
content: "revised".into(),
}));
assert!(
edited.contains(&format!("Comment #{cid} edited")),
"got: {edited}"
);
assert!(
!edited.contains("revised"),
"must not echo content: {edited}"
);
let listing = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
..Default::default()
}));
assert!(listing.contains("revised"), "got: {listing}");
assert!(
!listing.contains("original"),
"old body should be gone: {listing}"
);
}
#[test]
fn delete_comment_author_can_delete_own() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Deletable");
let author = make_user(&m, "author", false);
let _guard = act_as(&author);
let added = m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "delete me".into(),
}));
let cid = comment_id_from(&added);
let deleted = m.delete_comment(Parameters(DeleteCommentInput { comment_id: cid }));
assert!(
deleted.contains(&format!("Comment #{cid} deleted")),
"got: {deleted}"
);
let listing = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
..Default::default()
}));
assert!(
listing.contains("No comments"),
"comment should be gone: {listing}"
);
}
#[tokio::test]
async fn comment_mutations_link_the_live_comment_or_parent() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Linked comments");
let author = make_user(&m, "author", false);
let auth_user = models::AuthUser {
id: author.id,
username: author.username,
display_name: author.display_name,
is_admin: author.is_admin,
};
let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap();
let (comment_id, edited, deleted) =
crate::mcp::with_request_context(Some(auth_user), false, Some(context), || async {
let added = m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "original".into(),
}));
let comment_id = comment_id_from(&added);
let edited = m.edit_comment(Parameters(EditCommentInput {
comment_id,
content: "revised".into(),
}));
let deleted = m.delete_comment(Parameters(DeleteCommentInput { comment_id }));
(comment_id, edited, deleted)
})
.await;
assert!(
edited.contains(&format!(
"[comment #{comment_id}](https://tracker.example/PRJ/issues/PRJ-1#comment-{comment_id}) edited"
)),
"got: {edited}"
);
assert_eq!(
deleted,
format!(
"Comment #{comment_id} deleted from [PRJ-1](https://tracker.example/PRJ/issues/PRJ-1)"
)
);
}
#[test]
fn issue_comment_edit_and_delete_emit_updates() {
let (m, mut events) = mcp_with_realtime();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Realtime comments");
let project_id = project_id_for(&m, "PRJ");
let issue_id = issue_id_for(&m, "PRJ-1");
let author = make_user(&m, "author", false);
let _guard = act_as(&author);
let added = m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "original".into(),
}));
let comment_id = comment_id_from(&added);
drain_realtime(&mut events);
m.edit_comment(Parameters(EditCommentInput {
comment_id,
content: "revised".into(),
}));
assert_eq!(
realtime_events(&mut events),
vec![crate::realtime::RealtimeEvent::IssueUpdated {
project_id,
issue_id,
}]
);
m.delete_comment(Parameters(DeleteCommentInput { comment_id }));
assert_eq!(
realtime_events(&mut events),
vec![crate::realtime::RealtimeEvent::IssueUpdated {
project_id,
issue_id,
}]
);
}
#[test]
fn edit_and_delete_comment_refuse_non_author_non_admin() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Guarded");
let author = make_user(&m, "author", false);
let other = make_user(&m, "other", false);
let cid = {
let _g = act_as(&author);
let added = m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "mine".into(),
}));
comment_id_from(&added)
};
let _guard = act_as(&other);
let edit = m.edit_comment(Parameters(EditCommentInput {
comment_id: cid,
content: "hijacked".into(),
}));
assert!(
edit.contains("Error") && edit.contains("only edit your own"),
"non-author edit must be refused: {edit}"
);
let del = m.delete_comment(Parameters(DeleteCommentInput { comment_id: cid }));
assert!(
del.contains("Error") && del.contains("only delete your own"),
"non-author delete must be refused: {del}"
);
let listing = m.list_comments(Parameters(ListCommentsInput {
identifier: "PRJ-1".into(),
..Default::default()
}));
assert!(listing.contains("mine"), "comment must survive: {listing}");
}
#[test]
fn admin_can_delete_another_users_comment() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "AdminTarget");
let author = make_user(&m, "author", false);
let admin = make_user(&m, "boss", true);
let cid = {
let _g = act_as(&author);
let added = m.add_comment(Parameters(AddCommentInput {
identifier: "PRJ-1".into(),
content: "regular user's comment".into(),
}));
comment_id_from(&added)
};
let _guard = act_as(&admin);
let deleted = m.delete_comment(Parameters(DeleteCommentInput { comment_id: cid }));
assert!(
deleted.contains("deleted"),
"admin delete must succeed: {deleted}"
);
}
#[test]
fn edit_and_delete_comment_unknown_id_errors_cleanly() {
let m = mcp();
seed_project(&m, "Proj", "PRJ");
seed_issue(&m, "PRJ", "Empty");
let author = make_user(&m, "author", false);
let _guard = act_as(&author);
let edit = m.edit_comment(Parameters(EditCommentInput {
comment_id: 9999,
content: "nope".into(),
}));
assert!(edit.contains("Error"), "unknown edit must error: {edit}");
assert!(edit.contains("9999"), "error should name the id: {edit}");
let del = m.delete_comment(Parameters(DeleteCommentInput { comment_id: 9999 }));
assert!(del.contains("Error"), "unknown delete must error: {del}");
assert!(del.contains("9999"), "error should name the id: {del}");
}
#[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("widget".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}");
}
#[tokio::test]
async fn get_activity_links_the_resolved_resource_type() {
let m = mcp();
seed_project(&m, "Audit", "TST");
seed_issue(&m, "TST", "Audit issue");
m.create_page(Parameters(CreatePageInput {
project: Some("TST".into()),
title: "Audit page".into(),
content: None,
folder: None,
status: None,
labels: None,
}));
m.create_plan(Parameters(CreatePlanInput {
project: "TST".into(),
title: "Audit plan".into(),
anchor_issue: None,
steps: None,
}));
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "module".into(),
action: "create".into(),
project: Some("TST".into()),
name: Some("Backend".into()),
..Default::default()
}));
let author = make_user(&m, "author", false);
let auth_user = models::AuthUser {
id: author.id,
username: author.username,
display_name: author.display_name,
is_admin: author.is_admin,
};
let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap();
let (project, page, issue) =
crate::mcp::with_request_context(Some(auth_user), false, Some(context), || async {
m.add_comment(Parameters(AddCommentInput {
identifier: "TST-1".into(),
content: "Audit comment".into(),
}));
(
m.get_activity(Parameters(GetActivityInput {
identifier: "TST".into(),
..Default::default()
})),
m.get_activity(Parameters(GetActivityInput {
identifier: "TST-DOC-1".into(),
..Default::default()
})),
m.get_activity(Parameters(GetActivityInput {
identifier: "TST-1".into(),
..Default::default()
})),
)
})
.await;
assert!(
project.contains("activity entries for [TST](https://tracker.example/TST/overview):"),
"got: {project}"
);
assert!(
page.contains("activity entries for [TST-DOC-1](https://tracker.example/TST/pages/1):"),
"got: {page}"
);
for expected in [
"[TST-DOC-1](https://tracker.example/TST/pages/1)",
"[TST-PLAN-1](https://tracker.example/TST/plans/1)",
"[Backend](https://tracker.example/TST/modules/1)",
] {
assert!(project.contains(expected), "missing {expected}: {project}");
}
assert!(
issue.contains("[comment #1](https://tracker.example/TST/issues/TST-1#comment-1)"),
"got: {issue}"
);
}
#[tokio::test]
async fn get_activity_leaves_stale_identifiers_unlinked_after_project_rename() {
let m = mcp();
seed_project(&m, "Audit", "TST");
seed_issue(&m, "TST", "Historical issue");
let updated = m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "update".into(),
project: Some("TST".into()),
identifier: Some("NEW".into()),
..Default::default()
}));
assert!(updated.contains("Updated project NEW"), "got: {updated}");
let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap();
let out = crate::mcp::with_request_context(None, false, Some(context), || async {
m.get_activity(Parameters(GetActivityInput {
identifier: "NEW".into(),
..Default::default()
}))
})
.await;
assert!(out.contains("created issue TST-1"), "got: {out}");
assert!(!out.contains("[TST-1]("), "stale link in: {out}");
}
#[tokio::test]
async fn get_activity_leaves_stale_relation_targets_unlinked_after_project_rename() {
let m = mcp();
seed_project(&m, "Audit", "TST");
seed_issue(&m, "TST", "Source");
seed_issue(&m, "TST", "Target");
let linked = m.link_issues(Parameters(LinkIssuesInput {
source: "TST-1".into(),
target: "TST-2".into(),
relation_type: "blocks".into(),
}));
assert_eq!(linked, "TST-1 blocks TST-2");
let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap();
let (live, stale) =
crate::mcp::with_request_context(None, false, Some(context), || async {
let live = m.get_activity(Parameters(GetActivityInput {
identifier: "TST".into(),
..Default::default()
}));
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "update".into(),
project: Some("TST".into()),
identifier: Some("NEW".into()),
..Default::default()
}));
let stale = m.get_activity(Parameters(GetActivityInput {
identifier: "NEW".into(),
..Default::default()
}));
(live, stale)
})
.await;
assert!(
live.contains(
"[TST-1](https://tracker.example/TST/issues/TST-1) blocks → [TST-2](https://tracker.example/TST/issues/TST-2)"
),
"got: {live}"
);
assert!(stale.contains("TST-1 blocks → TST-2"), "got: {stale}");
assert!(
!stale.contains("[TST-2]("),
"stale relation link in: {stale}"
);
}
#[tokio::test]
async fn get_activity_links_plan_steps_to_their_parent_plan() {
let m = mcp();
seed_project(&m, "Audit", "TST");
let created = m.create_plan(Parameters(CreatePlanInput {
project: "TST".into(),
title: "Audit plan".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: "Exercise the link".into(),
..Default::default()
}]),
}));
assert!(created.contains("Created TST-PLAN-1"), "got: {created}");
let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap();
let out = crate::mcp::with_request_context(None, false, Some(context), || async {
m.get_activity(Parameters(GetActivityInput {
identifier: "TST".into(),
..Default::default()
}))
})
.await;
assert!(
out.contains(
"created plan_step [TST-PLAN-1](https://tracker.example/TST/plans/1): Exercise the link"
),
"got: {out}"
);
}
#[tokio::test]
async fn get_activity_links_plan_step_issue_values() {
let m = mcp();
seed_project(&m, "Audit", "TST");
seed_issue(&m, "TST", "Linked issue");
m.create_plan(Parameters(CreatePlanInput {
project: "TST".into(),
title: "Audit plan".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: "Exercise the link".into(),
..Default::default()
}]),
}));
let updated = m.update_plan_step(Parameters(UpdatePlanStepInput {
plan: "TST-PLAN-1".into(),
step_id: Some(1),
attach_issue: Some("TST-1".into()),
..Default::default()
}));
assert!(updated.contains("Attached TST-1"), "got: {updated}");
let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap();
let out = crate::mcp::with_request_context(None, false, Some(context), || async {
m.get_activity(Parameters(GetActivityInput {
identifier: "TST".into(),
..Default::default()
}))
})
.await;
assert!(
out.contains(
"[TST-PLAN-1](https://tracker.example/TST/plans/1) issue: (none) → [TST-1](https://tracker.example/TST/issues/TST-1)"
),
"got: {out}"
);
}
#[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,
..Default::default()
}));
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}"
);
}
#[test]
fn create_plan_authors_nested_tree_and_get_plan_rehydrates() {
let m = mcp();
seed_project(&m, "Plans", "PLN");
let created = m.create_plan(Parameters(CreatePlanInput {
project: "PLN".into(),
title: "Ship feature".into(),
anchor_issue: None,
steps: Some(vec![
PlanStepInput {
title: "Backend".into(),
steps: Some(vec![
PlanStepInput {
title: "schema".into(),
..Default::default()
},
PlanStepInput {
title: "queries".into(),
..Default::default()
},
]),
..Default::default()
},
PlanStepInput {
title: "Frontend".into(),
..Default::default()
},
]),
}));
assert!(created.contains("PLN-PLAN-1"), "got: {created}");
assert!(created.contains("Backend"));
assert!(created.contains("schema"));
crate::mcp::reset_issue_link_context_reads();
let got = m.get_plan(Parameters(GetPlanInput {
plan: "PLN-PLAN-1".into(),
}));
assert!(
got.contains("Frontend"),
"get_plan should rehydrate tree: {got}"
);
assert!(got.contains("0/4 done"), "header should count steps: {got}");
assert_eq!(crate::mcp::issue_link_context_reads(), 1);
}
#[tokio::test]
async fn attaching_an_issue_to_a_plan_step_links_its_canonical_identifier() {
let m = mcp();
seed_project(&m, "Plans", "PLN");
seed_issue(&m, "PLN", "Real work");
let created = m.create_plan(Parameters(CreatePlanInput {
project: "PLN".into(),
title: "Plan".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: "mirror".into(),
..Default::default()
}]),
}));
let step_id = created
.split('#')
.nth(1)
.and_then(|value| value.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|value| value.parse().ok())
.expect("step id in output");
let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap();
let output = crate::mcp::with_request_context(None, false, Some(context), || async {
m.update_plan_step(Parameters(UpdatePlanStepInput {
plan: "PLN-PLAN-1".into(),
step_id: Some(step_id),
attach_issue: Some("PLN-01".into()),
..Default::default()
}))
})
.await;
assert!(
output.contains("[PLN-1](https://tracker.example/PLN/issues/PLN-1)"),
"got: {output}"
);
assert!(!output.contains("/PLN-01"), "got: {output}");
}
#[test]
fn update_plan_step_done_closes_linked_issue_and_narrates() {
let m = mcp();
seed_project(&m, "Plans", "PLN");
seed_issue(&m, "PLN", "Real work");
let created = m.create_plan(Parameters(CreatePlanInput {
project: "PLN".into(),
title: "Plan".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: "mirror".into(),
issue: Some("PLN-1".into()),
..Default::default()
}]),
}));
let step_id: i64 = created
.split('#')
.nth(1)
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|s| s.parse().ok())
.expect("step id in output");
let out = m.update_plan_step(Parameters(UpdatePlanStepInput {
plan: "PLN-PLAN-1".into(),
step_id: Some(step_id),
done: Some(true),
..Default::default()
}));
assert!(
out.contains("→ PLN-1 marked done"),
"must narrate the issue side effect: {out}"
);
assert!(
out.contains("PLN-PLAN-1 [active]: 1/1 done"),
"receipt must carry progress counts: {out}"
);
assert!(
!out.contains("- [x]"),
"receipt must omit step lines: {out}"
);
let issue = m.get_issue(Parameters(GetIssueInput {
identifier: "PLN-1".into(),
..Default::default()
}));
assert!(issue.contains("done"), "issue should be done: {issue}");
}
#[test]
fn update_plan_step_echo_tree_returns_full_tree() {
let m = mcp();
seed_project(&m, "Plans", "PET");
let created = m.create_plan(Parameters(CreatePlanInput {
project: "PET".into(),
title: "Tree plan".into(),
anchor_issue: None,
steps: Some(vec![
PlanStepInput {
title: "first step".into(),
..Default::default()
},
PlanStepInput {
title: "second step".into(),
..Default::default()
},
]),
}));
let step_id: i64 = created
.split('#')
.nth(1)
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|s| s.parse().ok())
.expect("step id in output");
let out = m.update_plan_step(Parameters(UpdatePlanStepInput {
plan: "PET-PLAN-1".into(),
step_id: Some(step_id),
done: Some(true),
echo_tree: Some(true),
..Default::default()
}));
assert!(out.contains("first step"), "got: {out}");
assert!(out.contains("second step"), "got: {out}");
assert!(
out.contains("- ["),
"echo_tree must render step lines: {out}"
);
}
#[test]
fn update_plan_step_plan_level_returns_receipt() {
let m = mcp();
seed_project(&m, "Plans", "PPR");
m.create_plan(Parameters(CreatePlanInput {
project: "PPR".into(),
title: "Rename me".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: "only step".into(),
..Default::default()
}]),
}));
let out = m.update_plan_step(Parameters(UpdatePlanStepInput {
plan: "PPR-PLAN-1".into(),
step_id: None,
status: Some("done".into()),
..Default::default()
}));
assert!(
out.contains("PPR-PLAN-1 [done]: 0/1 done"),
"plan-level receipt must carry status + progress: {out}"
);
assert!(
!out.contains("only step"),
"receipt must omit the tree: {out}"
);
}
#[test]
fn update_plan_step_done_emits_issue_update() {
let (m, mut rx) = mcp_with_realtime();
seed_project(&m, "Plan Events", "PLE");
seed_issue(&m, "PLE", "Real work");
let project_id = project_id_for(&m, "PLE");
let issue_id = issue_id_for(&m, "PLE-1");
let created = m.create_plan(Parameters(CreatePlanInput {
project: "PLE".into(),
title: "Plan".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: "mirror".into(),
issue: Some("PLE-1".into()),
..Default::default()
}]),
}));
let step_id: i64 = created
.split('#')
.nth(1)
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|s| s.parse().ok())
.expect("step id in output");
drain_realtime(&mut rx);
m.update_plan_step(Parameters(UpdatePlanStepInput {
plan: "PLE-PLAN-1".into(),
step_id: Some(step_id),
done: Some(true),
..Default::default()
}));
assert_eq!(
realtime_events(&mut rx),
vec![
crate::realtime::RealtimeEvent::IssueUpdated {
project_id,
issue_id,
},
crate::realtime::RealtimeEvent::ProjectUpdated { project_id },
]
);
}
#[test]
fn edit_plan_step_find_replace() {
let m = mcp();
seed_project(&m, "Plans", "PLN");
let created = m.create_plan(Parameters(CreatePlanInput {
project: "PLN".into(),
title: "Plan".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: "step".into(),
description: Some("old text".into()),
..Default::default()
}]),
}));
let step_id: i64 = created
.split('#')
.nth(1)
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|s| s.parse().ok())
.unwrap();
let out = m.edit_plan_step(Parameters(EditPlanStepInput {
plan: "PLN-PLAN-1".into(),
step_id,
old_string: "old text".into(),
new_string: "new text".into(),
field: None,
replace_all: None,
}));
assert!(out.contains("Edited step"), "got: {out}");
let got = m.get_plan(Parameters(GetPlanInput {
plan: "PLN-PLAN-1".into(),
}));
assert!(got.contains("new text"), "edit should persist: {got}");
}
#[test]
fn plan_level_update_archives_and_lists() {
let m = mcp();
seed_project(&m, "Plans", "PLN");
m.create_plan(Parameters(CreatePlanInput {
project: "PLN".into(),
title: "Plan".into(),
anchor_issue: None,
steps: None,
}));
let out = m.update_plan_step(Parameters(UpdatePlanStepInput {
plan: "PLN-PLAN-1".into(),
step_id: None,
status: Some("archived".into()),
..Default::default()
}));
assert!(out.contains("archived"), "got: {out}");
let active = m.list_resources(Parameters(ListResourcesInput {
resource_type: "plan".into(),
project: Some("PLN".into()),
status: Some("active".into()),
..Default::default()
}));
assert!(
active.contains("No plans found."),
"archived plan must not show as active: {active}"
);
let archived = m.list_resources(Parameters(ListResourcesInput {
resource_type: "plan".into(),
project: Some("PLN".into()),
status: Some("archived".into()),
..Default::default()
}));
assert!(archived.contains("PLN-PLAN-1"), "got: {archived}");
}
#[test]
fn closing_issue_autocompletes_step_visible_in_get_plan() {
let m = mcp();
seed_project(&m, "Plans", "PLN");
seed_issue(&m, "PLN", "Mirrored work"); let created = m.create_plan(Parameters(CreatePlanInput {
project: "PLN".into(),
title: "Plan".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: "mirror".into(),
issue: Some("PLN-1".into()),
..Default::default()
}]),
}));
let step_id: i64 = created
.split('#')
.nth(1)
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|s| s.parse().ok())
.expect("step id in output");
let updated = m.update_issue(Parameters(UpdateIssueInput {
identifier: "PLN-1".into(),
status: Some("done".into()),
..Default::default()
}));
assert!(
updated.contains(&format!(
"auto-completed plan step #{step_id} in PLN-PLAN-1"
)),
"issue update must narrate the cascade: {updated}"
);
let got = m.get_plan(Parameters(GetPlanInput {
plan: "PLN-PLAN-1".into(),
}));
assert!(got.contains("[x]"), "step should be auto-completed: {got}");
assert!(got.contains("via PLN-1"), "provenance should show: {got}");
}
#[test]
fn reopening_issue_narrates_reopened_plan_step() {
let m = mcp();
seed_project(&m, "Plans", "RPN");
seed_issue(&m, "RPN", "Mirrored work"); let created = m.create_plan(Parameters(CreatePlanInput {
project: "RPN".into(),
title: "Plan".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: "mirror".into(),
issue: Some("RPN-1".into()),
..Default::default()
}]),
}));
let step_id: i64 = created
.split('#')
.nth(1)
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|s| s.parse().ok())
.expect("step id in output");
m.update_issue(Parameters(UpdateIssueInput {
identifier: "RPN-1".into(),
status: Some("done".into()),
..Default::default()
}));
let reopened = m.update_issue(Parameters(UpdateIssueInput {
identifier: "RPN-1".into(),
status: Some("todo".into()),
..Default::default()
}));
assert!(
reopened.contains(&format!("reopened plan step #{step_id} in RPN-PLAN-1")),
"issue reopen must narrate the cascade: {reopened}"
);
}
#[test]
fn closing_issue_skips_steps_in_archived_plans_without_note() {
let m = mcp();
seed_project(&m, "Plans", "ARC");
seed_issue(&m, "ARC", "Mirrored work"); m.create_plan(Parameters(CreatePlanInput {
project: "ARC".into(),
title: "Archived plan".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: "mirror".into(),
issue: Some("ARC-1".into()),
..Default::default()
}]),
}));
m.update_plan_step(Parameters(UpdatePlanStepInput {
plan: "ARC-PLAN-1".into(),
status: Some("archived".into()),
..Default::default()
}));
let updated = m.update_issue(Parameters(UpdateIssueInput {
identifier: "ARC-1".into(),
status: Some("done".into()),
..Default::default()
}));
assert_eq!(
updated, "Updated ARC-1: ARC-1 | done | none | Mirrored work",
"archived-plan steps are frozen and must not produce a note"
);
let plan = m.get_plan(Parameters(GetPlanInput {
plan: "ARC-PLAN-1".into(),
}));
assert!(
plan.contains("- [ ]"),
"archived step must remain open: {plan}"
);
}
#[test]
fn delete_plan_via_delete_tool() {
let m = mcp();
seed_project(&m, "Plans", "PLN");
m.create_plan(Parameters(CreatePlanInput {
project: "PLN".into(),
title: "Doomed".into(),
anchor_issue: None,
steps: None,
}));
let out = m.delete(Parameters(DeleteInput {
resource_type: "plan".into(),
identifier: "PLN-PLAN-01".into(),
project: None,
}));
assert_eq!(out, "Deleted plan PLN-PLAN-1");
let got = m.get_plan(Parameters(GetPlanInput {
plan: "PLN-PLAN-1".into(),
}));
assert!(
got.contains("Error"),
"deleted plan should not be found: {got}"
);
}
const RAW_TITLE: &str = r#"Fix & polish <Store<T>> "quotes""#;
fn assert_no_html_escape(s: &str, needles: &[&str]) {
for n in needles {
assert!(s.contains(n), "missing raw {n:?} in: {s}");
}
for ent in ["&", "<", ">", """, "&#"] {
assert!(!s.contains(ent), "found HTML entity {ent:?} in: {s}");
}
}
#[test]
fn no_html_escape_across_issue_read_surfaces() {
let m = mcp();
seed_project(&m, "Escape", "ESC");
let created = m.create_issue(Parameters(CreateIssueInput {
project: "ESC".into(),
title: RAW_TITLE.into(),
description: Some(r#"body with & and < and > and "quotes""#.into()),
..Default::default()
}));
assert_no_html_escape(&created, &[RAW_TITLE]);
let board = m.get_board(Parameters(GetBoardInput {
project: "ESC".into(),
..Default::default()
}));
assert_no_html_escape(&board, &[RAW_TITLE]);
let listed = m.list_resources(Parameters(ListResourcesInput {
resource_type: "issue".into(),
project: Some("ESC".into()),
..Default::default()
}));
assert_no_html_escape(&listed, &[RAW_TITLE]);
let issues = m.list_issues(Parameters(ListIssuesInput {
project: "ESC".into(),
..Default::default()
}));
assert_no_html_escape(&issues, &[RAW_TITLE]);
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "ESC-1".into(),
..Default::default()
}));
assert_no_html_escape(&detail, &[RAW_TITLE, "body with & and < and >"]);
let fts = m.search(Parameters(SearchInput {
query: "polish".into(),
..Default::default()
}));
assert_no_html_escape(&fts, &["Fix & polish"]);
let lit = m.search(Parameters(SearchInput {
query: "<Store<T>>".into(),
mode: Some("literal".into()),
..Default::default()
}));
assert_no_html_escape(&lit, &["<Store<T>>"]);
}
#[test]
fn no_html_escape_in_comment_surfaces() {
let m = mcp();
seed_project(&m, "Escape", "ESC");
seed_issue(&m, "ESC", "Host issue");
let _guard = seed_user(&m);
let raw_comment = r#"needs & review of <T> before "ship""#;
m.add_comment(Parameters(AddCommentInput {
identifier: "ESC-1".into(),
content: raw_comment.into(),
}));
let detail = m.get_issue(Parameters(GetIssueInput {
identifier: "ESC-1".into(),
include_comments: Some("all".into()),
}));
assert_no_html_escape(&detail, &[raw_comment]);
let comments = m.list_comments(Parameters(ListCommentsInput {
identifier: "ESC-1".into(),
..Default::default()
}));
assert_no_html_escape(&comments, &[raw_comment]);
}
#[test]
fn no_html_escape_in_plan_step_title() {
let m = mcp();
seed_project(&m, "Escape", "ESC");
let raw_step = r#"land A & B <fast> "now""#;
let created = m.create_plan(Parameters(CreatePlanInput {
project: "ESC".into(),
title: "Escaping plan".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: raw_step.into(),
..Default::default()
}]),
}));
assert_no_html_escape(&created, &[raw_step]);
let plan = m.get_plan(Parameters(GetPlanInput {
plan: "ESC-PLAN-1".into(),
}));
assert_no_html_escape(&plan, &[raw_step]);
}
}
#[cfg(test)]
mod authz_gating_tests {
use super::tests::setup_membership_mcp;
use super::*;
use rmcp::handler::server::wrapper::Parameters;
fn run<F: std::future::Future>(f: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(f)
}
fn as_user(user: &models::AuthUser, f: impl FnOnce() -> String) -> String {
run(crate::mcp::with_request_user(
Some(user.clone()),
|| async { f() },
))
}
fn is_forbidden(s: &str) -> bool {
s.starts_with("Error: Forbidden:")
}
#[test]
fn bulk_update_denies_non_member_when_enforced() {
let (m, _admin, _lead, maintainer, _viewer, non_member, _project_id) =
setup_membership_mcp();
let created = as_user(&maintainer, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: "Target".into(),
status: Some("active".into()),
priority: None,
description: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(created.starts_with("Created"), "got: {created}");
let denied = as_user(&non_member, || {
m.bulk_update(Parameters(BulkUpdateInput {
project: "MEM".into(),
filter_status: Some("active".into()),
set_status: Some("done".into()),
..Default::default()
}))
});
assert!(is_forbidden(&denied), "got: {denied}");
let allowed = as_user(&maintainer, || {
m.bulk_update(Parameters(BulkUpdateInput {
project: "MEM".into(),
filter_status: Some("active".into()),
set_status: Some("done".into()),
..Default::default()
}))
});
assert_eq!(allowed, "Updated 1 issue(s)", "got: {allowed}");
}
#[test]
fn issue_read_denies_non_member_allows_viewer() {
let (m, _admin, lead, _maintainer, viewer, non_member, project_id) = setup_membership_mcp();
let _ = project_id;
let created = as_user(&lead, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: "Secret work".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(created.starts_with("Created"), "got: {created}");
let denied = as_user(&non_member, || {
m.get_issue(Parameters(GetIssueInput {
identifier: "MEM-1".into(),
..Default::default()
}))
});
assert!(is_forbidden(&denied), "non-member get_issue: {denied}");
let allowed = as_user(&viewer, || {
m.get_issue(Parameters(GetIssueInput {
identifier: "MEM-1".into(),
..Default::default()
}))
});
assert!(!is_forbidden(&allowed), "viewer get_issue: {allowed}");
assert!(allowed.contains("Secret work"), "got: {allowed}");
}
#[test]
fn page_and_plan_reads_follow_the_same_viewer_gate() {
let (m, _admin, lead, _maintainer, viewer, non_member, _project_id) =
setup_membership_mcp();
let page = as_user(&lead, || {
m.create_page(Parameters(CreatePageInput {
project: Some("MEM".into()),
title: "Doc".into(),
content: None,
folder: None,
status: None,
labels: None,
}))
});
assert!(page.starts_with("Created"), "got: {page}");
let plan = as_user(&lead, || {
m.create_plan(Parameters(CreatePlanInput {
project: "MEM".into(),
title: "Plan".into(),
anchor_issue: None,
steps: None,
}))
});
assert!(plan.starts_with("Created"), "got: {plan}");
let denied_page = as_user(&non_member, || {
m.get_page(Parameters(GetPageInput {
identifier: "MEM-DOC-1".into(),
}))
});
assert!(is_forbidden(&denied_page), "got: {denied_page}");
let denied_plan = as_user(&non_member, || {
m.get_plan(Parameters(GetPlanInput {
plan: "MEM-PLAN-1".into(),
}))
});
assert!(is_forbidden(&denied_plan), "got: {denied_plan}");
let allowed_page = as_user(&viewer, || {
m.get_page(Parameters(GetPageInput {
identifier: "MEM-DOC-1".into(),
}))
});
assert!(!is_forbidden(&allowed_page), "got: {allowed_page}");
let allowed_plan = as_user(&viewer, || {
m.get_plan(Parameters(GetPlanInput {
plan: "MEM-PLAN-1".into(),
}))
});
assert!(!is_forbidden(&allowed_plan), "got: {allowed_plan}");
}
#[test]
fn search_and_list_resources_project_filter_instead_of_denying() {
let (m, _admin, lead, _maintainer, viewer, non_member, _project_id) =
setup_membership_mcp();
let created = as_user(&lead, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: "Unique searchable xyzzy".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(created.starts_with("Created"), "got: {created}");
let denied = as_user(&non_member, || {
m.search(Parameters(SearchInput {
query: "xyzzy".into(),
..Default::default()
}))
});
assert!(!is_forbidden(&denied), "search must not error: {denied}");
assert!(denied.contains("No results"), "got: {denied}");
let found = as_user(&viewer, || {
m.search(Parameters(SearchInput {
query: "xyzzy".into(),
..Default::default()
}))
});
assert!(found.contains("1 results"), "got: {found}");
let none_visible = as_user(&non_member, || {
m.list_resources(Parameters(ListResourcesInput {
resource_type: "project".into(),
..Default::default()
}))
});
assert!(
none_visible.starts_with("0 projects"),
"got: {none_visible}"
);
let one_visible = as_user(&viewer, || {
m.list_resources(Parameters(ListResourcesInput {
resource_type: "project".into(),
..Default::default()
}))
});
assert!(one_visible.starts_with("1 projects"), "got: {one_visible}");
}
#[test]
fn nudge_not_shown_when_projects_exist_but_none_visible() {
let (m, _admin, _lead, _maintainer, _viewer, non_member, _project_id) =
setup_membership_mcp();
let projects = as_user(&non_member, || {
m.list_resources(Parameters(ListResourcesInput {
resource_type: "project".into(),
..Default::default()
}))
});
assert!(
!projects.contains(NO_PROJECTS_NUDGE),
"leaked nudge: {projects}"
);
assert!(projects.starts_with("0 projects"), "got: {projects}");
let searched = as_user(&non_member, || {
m.search(Parameters(SearchInput {
query: "anything".into(),
..Default::default()
}))
});
assert!(
!searched.contains(NO_PROJECTS_NUDGE),
"leaked nudge: {searched}"
);
}
#[test]
fn issue_create_gated_by_maintainer_role() {
let (m, admin, lead, maintainer, viewer, non_member, _project_id) = setup_membership_mcp();
for (user, expect_ok) in [
(&non_member, false),
(&viewer, false),
(&maintainer, true),
(&lead, true),
(&admin, true),
] {
let result = as_user(user, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: format!("by {}", user.username),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert_eq!(
is_forbidden(&result),
!expect_ok,
"{} create_issue expected ok={expect_ok}, got: {result}",
user.username
);
}
}
#[test]
fn issue_update_and_delete_gated_by_maintainer_role() {
let (m, _admin, lead, maintainer, viewer, non_member, _project_id) = setup_membership_mcp();
let created = as_user(&maintainer, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: "Target".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(created.starts_with("Created"), "got: {created}");
let denied = as_user(&viewer, || {
m.update_issue(Parameters(UpdateIssueInput {
identifier: "MEM-1".into(),
title: Some("hijack".into()),
..Default::default()
}))
});
assert!(is_forbidden(&denied), "got: {denied}");
let denied2 = as_user(&non_member, || {
m.update_issue(Parameters(UpdateIssueInput {
identifier: "MEM-1".into(),
title: Some("hijack".into()),
..Default::default()
}))
});
assert!(is_forbidden(&denied2), "got: {denied2}");
let allowed = as_user(&lead, || {
m.update_issue(Parameters(UpdateIssueInput {
identifier: "MEM-1".into(),
title: Some("renamed".into()),
..Default::default()
}))
});
assert!(!is_forbidden(&allowed), "got: {allowed}");
let denied_delete = as_user(&viewer, || {
m.delete(Parameters(DeleteInput {
resource_type: "issue".into(),
identifier: "MEM-1".into(),
project: None,
}))
});
assert!(is_forbidden(&denied_delete), "got: {denied_delete}");
let allowed_delete = as_user(&lead, || {
m.delete(Parameters(DeleteInput {
resource_type: "issue".into(),
identifier: "MEM-1".into(),
project: None,
}))
});
assert!(!is_forbidden(&allowed_delete), "got: {allowed_delete}");
}
#[test]
fn comment_create_allows_viewer_denies_non_member() {
let (m, _admin, lead, _maintainer, viewer, non_member, _project_id) =
setup_membership_mcp();
let created = as_user(&lead, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: "Commentable".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(created.starts_with("Created"), "got: {created}");
let allowed = as_user(&viewer, || {
m.add_comment(Parameters(AddCommentInput {
identifier: "MEM-1".into(),
content: "viewers can comment".into(),
}))
});
assert!(
!is_forbidden(&allowed),
"viewer must be allowed to comment: {allowed}"
);
let denied = as_user(&non_member, || {
m.add_comment(Parameters(AddCommentInput {
identifier: "MEM-1".into(),
content: "should not land".into(),
}))
});
assert!(is_forbidden(&denied), "got: {denied}");
}
#[test]
fn structure_endpoints_viewer_denied_maintainer_allowed() {
let (m, _admin, _lead, maintainer, viewer, non_member, _project_id) =
setup_membership_mcp();
let denied = as_user(&viewer, || {
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "module".into(),
action: "create".into(),
project: Some("MEM".into()),
name: Some("Nope".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: None,
}))
});
assert!(is_forbidden(&denied), "got: {denied}");
let denied2 = as_user(&non_member, || {
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "label".into(),
action: "create".into(),
project: Some("MEM".into()),
name: Some("nope".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: None,
}))
});
assert!(is_forbidden(&denied2), "got: {denied2}");
let allowed = as_user(&maintainer, || {
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "module".into(),
action: "create".into(),
project: Some("MEM".into()),
name: Some("Backend".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: None,
}))
});
assert!(
!is_forbidden(&allowed),
"maintainer should manage structure once enforcement loosens the gate: {allowed}"
);
let allowed2 = as_user(&maintainer, || {
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "folder".into(),
action: "create".into(),
project: Some("MEM".into()),
name: Some("Docs".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: None,
}))
});
assert!(!is_forbidden(&allowed2), "got: {allowed2}");
let denied_settings = as_user(&maintainer, || {
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "update".into(),
project: Some("MEM".into()),
name: Some("Nope".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: None,
}))
});
assert!(is_forbidden(&denied_settings), "got: {denied_settings}");
let denied_delete = as_user(&maintainer, || {
m.delete(Parameters(DeleteInput {
resource_type: "project".into(),
identifier: "MEM".into(),
project: None,
}))
});
assert!(is_forbidden(&denied_delete), "got: {denied_delete}");
}
#[test]
fn project_settings_update_maintainer_denied_lead_allowed() {
let (m, _admin, lead, maintainer, _viewer, _non_member, _project_id) =
setup_membership_mcp();
let denied = as_user(&maintainer, || {
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "update".into(),
project: Some("MEM".into()),
name: Some("Nope".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: None,
}))
});
assert!(is_forbidden(&denied), "got: {denied}");
let allowed = as_user(&lead, || {
m.manage_resource(Parameters(ManageResourceInput {
resource_type: "project".into(),
action: "update".into(),
project: Some("MEM".into()),
name: Some("Renamed".into()),
identifier: None,
description: None,
current_name: None,
status: None,
color: None,
emoji: None,
}))
});
assert!(!is_forbidden(&allowed), "got: {allowed}");
}
#[test]
fn project_delete_maintainer_denied_lead_allowed_when_enforced() {
let (m, _admin, lead, maintainer, _viewer, _non_member, _project_id) =
setup_membership_mcp();
let denied = as_user(&maintainer, || {
m.delete(Parameters(DeleteInput {
resource_type: "project".into(),
identifier: "MEM".into(),
project: None,
}))
});
assert!(is_forbidden(&denied), "got: {denied}");
let allowed = as_user(&lead, || {
m.delete(Parameters(DeleteInput {
resource_type: "project".into(),
identifier: "MEM".into(),
project: None,
}))
});
assert!(!is_forbidden(&allowed), "got: {allowed}");
}
#[test]
fn relation_link_requires_maintainer_on_both_projects() {
let (m, _admin, lead, maintainer, _viewer, _non_member, project_id) =
setup_membership_mcp();
let issue_a = as_user(&lead, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: "A".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(issue_a.starts_with("Created"), "got: {issue_a}");
let other_project_id = {
let conn = m.db.write().unwrap();
crate::db::queries::create_project(
&conn,
&models::CreateProject {
name: "Other".into(),
identifier: "OTH".into(),
description: String::new(),
emoji: None,
lead_user_id: Some(lead.id),
},
)
.unwrap()
.id
};
let _ = project_id;
let issue_b = as_user(&lead, || {
m.create_issue(Parameters(CreateIssueInput {
project: "OTH".into(),
title: "B".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(issue_b.starts_with("Created"), "got: {issue_b}");
let denied = as_user(&maintainer, || {
m.link_issues(Parameters(LinkIssuesInput {
source: "MEM-1".into(),
target: "OTH-1".into(),
relation_type: "relates_to".into(),
}))
});
assert!(
is_forbidden(&denied),
"maintainer has no role on target's project: {denied}"
);
{
let conn = m.db.write().unwrap();
crate::db::queries::members::upsert_member(
&conn,
other_project_id,
maintainer.id,
models::Role::Maintainer,
)
.unwrap();
}
let allowed = as_user(&maintainer, || {
m.link_issues(Parameters(LinkIssuesInput {
source: "MEM-1".into(),
target: "OTH-1".into(),
relation_type: "relates_to".into(),
}))
});
assert!(
!is_forbidden(&allowed),
"maintainer now has Maintainer on both sides: {allowed}"
);
}
#[test]
fn plan_step_attach_issue_requires_maintainer_on_issue_project() {
let (m, _admin, lead, maintainer, _viewer, _non_member, _project_id) =
setup_membership_mcp();
let plan = as_user(&maintainer, || {
m.create_plan(Parameters(CreatePlanInput {
project: "MEM".into(),
title: "Plan".into(),
anchor_issue: None,
steps: Some(vec![PlanStepInput {
title: "step".into(),
..Default::default()
}]),
}))
});
assert!(plan.starts_with("Created"), "got: {plan}");
let step_id: i64 = plan
.split('#')
.nth(1)
.and_then(|s| s.split_whitespace().next())
.unwrap()
.parse()
.unwrap();
let other_project_id = {
let conn = m.db.write().unwrap();
crate::db::queries::create_project(
&conn,
&models::CreateProject {
name: "Other".into(),
identifier: "OT2".into(),
description: String::new(),
emoji: None,
lead_user_id: Some(lead.id),
},
)
.unwrap()
.id
};
let foreign_issue = as_user(&lead, || {
m.create_issue(Parameters(CreateIssueInput {
project: "OT2".into(),
title: "Foreign".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(foreign_issue.starts_with("Created"), "got: {foreign_issue}");
let denied = as_user(&maintainer, || {
m.update_plan_step(Parameters(UpdatePlanStepInput {
plan: "MEM-PLAN-1".into(),
step_id: Some(step_id),
attach_issue: Some("OT2-1".into()),
..Default::default()
}))
});
assert!(
is_forbidden(&denied),
"maintainer has no role on the foreign issue's project: {denied}"
);
{
let conn = m.db.write().unwrap();
crate::db::queries::members::upsert_member(
&conn,
other_project_id,
maintainer.id,
models::Role::Maintainer,
)
.unwrap();
}
let allowed = as_user(&maintainer, || {
m.update_plan_step(Parameters(UpdatePlanStepInput {
plan: "MEM-PLAN-1".into(),
step_id: Some(step_id),
attach_issue: Some("OT2-1".into()),
..Default::default()
}))
});
assert!(!is_forbidden(&allowed), "got: {allowed}");
}
#[test]
fn workspace_page_mutation_requires_admin() {
let (m, admin, _lead, maintainer, _viewer, _non_member, _project_id) =
setup_membership_mcp();
let denied = as_user(&maintainer, || {
m.create_page(Parameters(CreatePageInput {
project: None,
title: "Workspace doc".into(),
content: None,
folder: None,
status: None,
labels: None,
}))
});
assert!(is_forbidden(&denied), "got: {denied}");
let allowed = as_user(&admin, || {
m.create_page(Parameters(CreatePageInput {
project: None,
title: "Workspace doc".into(),
content: None,
folder: None,
status: None,
labels: None,
}))
});
assert!(!is_forbidden(&allowed), "got: {allowed}");
}
#[test]
fn bot_owned_by_maintainer_inherits_role() {
let (m, _admin, _lead, maintainer, _viewer, _non_member, _project_id) =
setup_membership_mcp();
let bot = {
let conn = m.db.write().unwrap();
let bot =
crate::db::queries::users::create_bot_user(&conn, maintainer.id, "bot1", "bot1")
.unwrap();
models::AuthUser {
id: bot.id,
username: bot.username,
display_name: bot.display_name,
is_admin: bot.is_admin,
}
};
let created = as_user(&bot, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: "Bot-made".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(
!is_forbidden(&created),
"bot inherits maintainer's write access: {created}"
);
let read = as_user(&bot, || {
m.get_issue(Parameters(GetIssueInput {
identifier: "MEM-1".into(),
..Default::default()
}))
});
assert!(
!is_forbidden(&read),
"bot inherits maintainer's read access: {read}"
);
}
#[test]
fn non_member_denied_on_reads_mutations_and_delete() {
let (m, _admin, lead, _maintainer, _viewer, non_member, _project_id) =
setup_membership_mcp();
let created = as_user(&lead, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: "Guarded".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(created.starts_with("Created"), "got: {created}");
let read = as_user(&non_member, || {
m.get_issue(Parameters(GetIssueInput {
identifier: "MEM-1".into(),
..Default::default()
}))
});
assert!(is_forbidden(&read), "read: {read}");
let mutate = as_user(&non_member, || {
m.update_issue(Parameters(UpdateIssueInput {
identifier: "MEM-1".into(),
title: Some("hijacked".into()),
..Default::default()
}))
});
assert!(is_forbidden(&mutate), "mutation: {mutate}");
let delete = as_user(&non_member, || {
m.delete(Parameters(DeleteInput {
resource_type: "issue".into(),
identifier: "MEM-1".into(),
project: None,
}))
});
assert!(is_forbidden(&delete), "delete: {delete}");
}
#[test]
fn admin_non_member_can_read_and_write_via_mcp() {
let (m, admin, lead, _maintainer, _viewer, _non_member, _project_id) =
setup_membership_mcp();
let created = as_user(&lead, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: "Admin spot-check".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(created.starts_with("Created"), "got: {created}");
let read = as_user(&admin, || {
m.get_issue(Parameters(GetIssueInput {
identifier: "MEM-1".into(),
..Default::default()
}))
});
assert!(
!is_forbidden(&read),
"admin must read a project they're not a member of: {read}"
);
assert!(read.contains("Admin spot-check"), "got: {read}");
let write = as_user(&admin, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: "by admin".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(
!is_forbidden(&write),
"admin must write to a project they're not a member of: {write}"
);
}
#[test]
fn oauth_token_backed_member_succeeds_non_member_denied_when_enforced() {
use axum::body::Body;
use axum::extract::{Json as JsonExtract, Path as PathExtract, State};
use axum::http::{Request, StatusCode};
use axum::routing::{get, post};
use axum::{Extension, Router};
use rusqlite::params;
use sha2::{Digest, Sha256};
use tower::ServiceExt;
let (m, _admin, lead, maintainer, _viewer, non_member, _project_id) =
setup_membership_mcp();
let created = as_user(&lead, || {
m.create_issue(Parameters(CreateIssueInput {
project: "MEM".into(),
title: "Token-guarded".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(created.starts_with("Created"), "got: {created}");
fn insert_oauth_token(db: &crate::db::DbPool, suffix: &str, user_id: i64) -> String {
let token = format!("lific_at_test-{suffix}");
let hash: String = Sha256::digest(token.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let expires = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let client_id = format!("client-{suffix}");
let conn = db.write().unwrap();
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?1, 'Test', '[\"http://localhost\"]')",
params![client_id],
)
.unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, user_id) VALUES (?1, ?2, ?3, 'mcp', ?4)",
params![hash, client_id, expires, user_id],
)
.unwrap();
token
}
let member_token = insert_oauth_token(&m.db, "member", maintainer.id);
let outsider_token = insert_oauth_token(&m.db, "outsider", non_member.id);
async fn get_issue_h(
State(mcp): State<LificMcp>,
Extension(auth_user): Extension<Option<models::AuthUser>>,
PathExtract(ident): PathExtract<String>,
) -> String {
crate::mcp::with_request_user(auth_user, || async move {
mcp.get_issue(Parameters(GetIssueInput {
identifier: ident,
..Default::default()
}))
})
.await
}
async fn create_issue_h(
State(mcp): State<LificMcp>,
Extension(auth_user): Extension<Option<models::AuthUser>>,
JsonExtract(input): JsonExtract<CreateIssueInput>,
) -> String {
crate::mcp::with_request_user(auth_user, || async move {
mcp.create_issue(Parameters(input))
})
.await
}
let auth_state = crate::auth::AuthState {
db: (*m.db).clone(),
manager: crate::auth::create_key_manager().unwrap(),
public_url: "https://example.com".into(),
required: true,
};
let app = Router::new()
.route("/call/get_issue/{ident}", get(get_issue_h))
.route("/call/create_issue", post(create_issue_h))
.layer(axum::middleware::from_fn_with_state(
auth_state,
crate::auth::require_api_key,
))
.with_state(m.clone());
async fn call_get(app: Router, uri: String, token: &str) -> String {
let resp = app
.oneshot(
Request::builder()
.uri(uri)
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"dispatcher itself must not error"
);
let bytes = http_body_util::BodyExt::collect(resp.into_body())
.await
.unwrap()
.to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn call_create(app: Router, body: serde_json::Value, token: &str) -> String {
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/call/create_issue")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"dispatcher itself must not error"
);
let bytes = http_body_util::BodyExt::collect(resp.into_body())
.await
.unwrap()
.to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
let member_read = run(call_get(
app.clone(),
"/call/get_issue/MEM-1".into(),
&member_token,
));
assert!(
!is_forbidden(&member_read),
"token-backed member must be able to read: {member_read}"
);
let member_write = run(call_create(
app.clone(),
serde_json::json!({"project": "MEM", "title": "by token member"}),
&member_token,
));
assert!(
!is_forbidden(&member_write),
"token-backed member must be able to write: {member_write}"
);
let outsider_read = run(call_get(
app.clone(),
"/call/get_issue/MEM-1".into(),
&outsider_token,
));
assert!(
is_forbidden(&outsider_read),
"token-backed non-member must be denied on read: {outsider_read}"
);
let outsider_write = run(call_create(
app.clone(),
serde_json::json!({"project": "MEM", "title": "by token outsider"}),
&outsider_token,
));
assert!(
is_forbidden(&outsider_write),
"token-backed non-member must be denied on write: {outsider_write}"
);
}
#[test]
fn flag_off_non_member_agent_can_still_mutate() {
let db = crate::db::open_memory().expect("test db");
let (project_lead, outsider) = {
let conn = db.write().unwrap();
let lead = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "lead".into(),
email: "lead@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let outsider = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "outsider".into(),
email: "outsider@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
crate::db::queries::create_project(
&conn,
&models::CreateProject {
name: "Legacy".into(),
identifier: "LEG".into(),
description: String::new(),
emoji: None,
lead_user_id: Some(lead.id),
},
)
.unwrap();
(lead, outsider)
};
let m = LificMcp::new(db);
let outsider_user = models::AuthUser {
id: outsider.id,
username: outsider.username,
display_name: outsider.display_name,
is_admin: outsider.is_admin,
};
let _ = project_lead;
let result = as_user(&outsider_user, || {
m.create_issue(Parameters(CreateIssueInput {
project: "LEG".into(),
title: "Legacy open".into(),
description: None,
status: None,
priority: None,
module: None,
labels: None,
..Default::default()
}))
});
assert!(
!is_forbidden(&result),
"flag off: a non-member agent must still be able to mutate (MCP's historical behavior): {result}"
);
}
}