//! The MCP tool surface, calling vissue-core in process.
use rmcp::{
ErrorData as McpError, handler::server::ServerHandler, handler::server::wrapper::Json,
handler::server::wrapper::Parameters, model::*, tool, tool_handler, tool_router,
};
use vissue_core::config::Layout;
use vissue_core::error::Error;
use vissue_core::mirror::{self, Format};
use vissue_core::ops::{self, CreateOpts, RejectOpts, UpdatePred};
use vissue_core::router::Router;
use vissue_core::views::{IssueDetail, IssueRow};
use vissue_core::{agent, events, report};
/// A structured answer, or the error as the protocol carries one.
///
/// Four tools return data rather than prose, and a client that has to parse a
/// pretty-printed string back into the object it already was is doing work the
/// protocol has a field for. `Json` fills `structuredContent` and leaves the
/// serialized text beside it, so a caller that reads either still works.
fn structured<T, E: std::fmt::Display>(result: Result<T, E>) -> Result<Json<T>, McpError> {
result
.map(Json)
.map_err(|e| McpError::internal_error(format!("{e}"), None))
}
use crate::tools::*;
use std::path::PathBuf;
/// The tool router is built by `#[tool_handler]` through `Self::tool_router()`,
/// so the server carries the default layout and the user-level project router.
#[derive(Clone)]
pub struct VissueServer {
layout: Layout,
router: Router,
}
/// Where a `reject` should put its successor.
struct RejectDest {
layout: Layout,
project: Option<String>,
extra_id_paths: Vec<PathBuf>,
}
fn text<E: std::fmt::Display>(result: Result<String, E>) -> Result<CallToolResult, McpError> {
match result {
Ok(s) => Ok(CallToolResult::success(vec![ContentBlock::text(s)])),
Err(e) => Err(McpError::internal_error(format!("{e}"), None)),
}
}
#[tool_router]
impl VissueServer {
/// Resolve the layout from `VISSUE_ROOT` and `VISSUE_PREFIX`, or the
/// current directory, then load the user-level route table.
pub fn from_env() -> anyhow::Result<Self> {
let layout = Layout::resolve(None, None)?;
let router = Router::load(layout.clone())?;
Ok(Self { layout, router })
}
#[cfg(test)]
pub fn with_layout(layout: Layout) -> Self {
Self {
router: Router::unrouted(layout.clone()),
layout,
}
}
fn layout_for_id(&self, id: &str) -> vissue_core::Result<Layout> {
Ok(self.router.find_by_id(id)?.layout)
}
/// A known id routes to its own layout. An accession names a product
/// rather than a heading, so it has no layout of its own and every tracker
/// in reach can cite it.
///
/// Only "no such heading" falls through to the accession walk. A duplicate
/// id or an unreadable file is a fault in the corpus, and answering it with
/// a citation list would report that fault as an empty result.
fn backlinks_text(&self, id: &str) -> vissue_core::Result<String> {
match self.layout_for_id(id) {
Ok(layout) => report::backlinks(&layout, id),
Err(Error::IssueNotFound { .. }) if ops::is_deed_accession(id) => {
let mut out = String::new();
for layout in self.router.unique_layouts() {
out.push_str(&report::backlinks(layout, id)?);
}
Ok(out)
}
Err(err) => Err(err),
}
}
/// `to` names an existing heading, so its own layout wins. Otherwise the
/// create project is routed, which keeps a bounce onto a routed name off
/// the server's own root.
fn reject_destination(
&self,
src: &Layout,
to: Option<&str>,
project: Option<&str>,
) -> vissue_core::Result<RejectDest> {
if let Some(to) = to {
return Ok(RejectDest {
layout: self.layout_for_id(to)?,
project: project.map(str::to_string),
extra_id_paths: Vec::new(),
});
}
let Some(project) = project else {
return Ok(RejectDest {
layout: src.clone(),
project: None,
extra_id_paths: Vec::new(),
});
};
let pref = self.router.route(project);
let extra_id_paths = self.router.extra_id_paths_for(&pref.dir);
Ok(RejectDest {
layout: pref.layout,
project: Some(pref.dir),
extra_id_paths,
})
}
#[tool(
description = "List the projects that hold an issues.org under the tracker root.",
annotations(
title = "List projects",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_projects(&self) -> Result<CallToolResult, McpError> {
text(self.router.visible_projects().map(|ps| {
format!(
"{}\n",
ps.into_iter().map(|p| p.key).collect::<Vec<_>>().join("\n")
)
}))
}
#[tool(
description = "List issues, optionally filtered by project and state.",
annotations(title = "List issues", read_only_hint = true, open_world_hint = false)
)]
async fn vissue_list(
&self,
Parameters(args): Parameters<ListArgs>,
) -> Result<Json<Vec<IssueRow>>, McpError> {
structured(issue_rows_routed(
&self.router,
args.project.as_deref(),
args.state.as_deref(),
false,
))
}
#[tool(
description = "List actionable issues: TODO or STARTED with no open blocker.",
annotations(
title = "Actionable issues",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_ready(
&self,
Parameters(args): Parameters<ProjectArgs>,
) -> Result<Json<Vec<IssueRow>>, McpError> {
structured(issue_rows_routed(
&self.router,
args.project.as_deref(),
None,
true,
))
}
#[tool(
description = "Show one issue's metadata and file range. Never returns body prose.",
annotations(
title = "Show an issue",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_show(
&self,
Parameters(args): Parameters<IdArgs>,
) -> Result<Json<IssueDetail>, McpError> {
structured(
self.layout_for_id(&args.issue_id)
.and_then(|layout| agent::show_detail(&layout, &args.issue_id)),
)
}
#[tool(
description = "Create an issue in a project's issues.org.",
annotations(
title = "Create an issue",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = false
)
)]
async fn vissue_create(
&self,
Parameters(args): Parameters<CreateArgs>,
) -> Result<CallToolResult, McpError> {
text(create_routed(
&self.router,
&args.project,
&args.title,
CreateOpts {
priority: priority_char(args.priority.as_ref()),
issue_type: args.issue_type.as_deref(),
tags: args.tags.as_deref(),
parent: args.parent.as_deref(),
body: args.body.as_deref(),
deadline: args.deadline.as_deref(),
scheduled: args.scheduled.as_deref(),
..Default::default()
},
))
}
#[tool(
description = "Reject an issue by redirecting it to an existing destination (`to`) or a newly created replacement (`project` + `title`).",
annotations(
title = "Reject an issue",
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = false
)
)]
async fn vissue_reject(
&self,
Parameters(args): Parameters<RejectArgs>,
) -> Result<CallToolResult, McpError> {
text(self.layout_for_id(&args.issue_id).and_then(|layout| {
let dest =
self.reject_destination(&layout, args.to.as_deref(), args.project.as_deref())?;
ops::reject(
&layout,
&args.issue_id,
RejectOpts {
to: args.to.as_deref(),
project: dest.project.as_deref(),
title: args.title.as_deref(),
reason: args.reason.as_deref(),
dst_layout: Some(&dest.layout),
dst_extra_id_paths: &dest.extra_id_paths,
},
)
}))
}
#[tool(
description = "Pick one terminal after a sibling close (DONE or CANCELLED).",
annotations(
title = "Resolve a sibling close",
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn vissue_resolve(
&self,
Parameters(args): Parameters<ResolveArgs>,
) -> Result<CallToolResult, McpError> {
text(
self.layout_for_id(&args.issue_id)
.and_then(|layout| ops::resolve_terminal(&layout, &args.issue_id, &args.state)),
)
}
#[tool(
description = "Update an issue's state, priority, or blocker edges.",
annotations(
title = "Update an issue",
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn vissue_update(
&self,
Parameters(args): Parameters<UpdateArgs>,
) -> Result<CallToolResult, McpError> {
let outcome = self.layout_for_id(&args.issue_id).and_then(|layout| {
ops::update_pred(
&layout,
&args.issue_id,
args.state.as_deref(),
priority_char(args.priority.as_ref()),
args.block.as_deref(),
args.unblock.as_deref(),
UpdatePred {
if_state: args.if_state.as_deref(),
if_gen: args.if_gen,
},
)
});
text(outcome.map(|o| {
let mut s = o.report;
for hint in o.hints {
s.push_str(&format!("[hint] {hint}\n"));
}
s
}))
}
#[tool(
description = "Claim an issue: move it to STARTED and stamp the claiming identity.",
annotations(
title = "Claim an issue",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn vissue_claim(
&self,
Parameters(args): Parameters<ClaimArgs>,
) -> Result<CallToolResult, McpError> {
text(
self.layout_for_id(&args.issue_id).and_then(|layout| {
agent::claim(&layout, &args.issue_id, args.force.unwrap_or(false))
}),
)
}
#[tool(
description = "Append a dated report to an issue's body. Use this to record work that was done: the logbook holds one line per event, so a written report belongs in the body. Markdown is safe.",
annotations(
title = "Append a report",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = false
)
)]
async fn vissue_append(
&self,
Parameters(args): Parameters<AppendArgs>,
) -> Result<CallToolResult, McpError> {
text(
self.layout_for_id(&args.issue_id)
.and_then(|layout| ops::append_body(&layout, &args.issue_id, &args.text)),
)
}
#[tool(
description = "Add a dated note to an issue's logbook without touching state or claim.",
annotations(
title = "Add a logbook note",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = false
)
)]
async fn vissue_note(
&self,
Parameters(args): Parameters<NoteArgs>,
) -> Result<CallToolResult, McpError> {
text(
self.layout_for_id(&args.issue_id)
.and_then(|layout| ops::note(&layout, &args.issue_id, &args.text)),
)
}
#[tool(
description = "Cast this agent's vote on an issue, or read the tally when no choice is given. One ballot per identity: voting again replaces your own ballot and never another agent's. The tally separates a majority from a plurality and from a tie, so consult it before acting on what looks like agreement.",
annotations(
title = "Cast a ballot",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn vissue_vote(
&self,
Parameters(args): Parameters<VoteArgs>,
) -> Result<CallToolResult, McpError> {
text(self.layout_for_id(&args.issue_id).and_then(|layout| {
let who = vissue_core::config::identity(&layout);
ops::vote(&layout, &args.issue_id, args.choice.as_deref(), &who)
}))
}
#[tool(
description = "Cite, drop, or list the deeds this issue's work produced. A deed is deedar's frozen record of a product: name the accession here when work finishes, and the next unit opens it with `deedar get` instead of rereading a transcript. Omit both lists to read the citations. Accessions are `deed-<kind>-<slug>`, or a `sha256:` of the deed or of one product path.",
annotations(
title = "Cite or drop deeds",
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn vissue_deed(
&self,
Parameters(args): Parameters<DeedArgs>,
) -> Result<CallToolResult, McpError> {
let add = args.add.unwrap_or_default();
let remove = args.remove.unwrap_or_default();
text(
self.layout_for_id(&args.issue_id)
.and_then(|layout| ops::deed(&layout, &args.issue_id, &add, &remove)),
)
}
#[tool(
description = "The working set for an issue: the plan it sits in, the deeds produced by what blocks it, the issue it was bounced from, and what it has produced itself. Read this before starting work on a node. Assembled from the declared edges rather than by resemblance, so it is what the plan says the work stands on and not a ranked guess; `vissue_related` answers the resemblance question. Set `excerpts` to splice in what each input concluded, which is in its body rather than in the deed it named.",
annotations(
title = "Working set for an issue",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_recall(
&self,
Parameters(args): Parameters<RecallArgs>,
) -> Result<CallToolResult, McpError> {
text(self.layout_for_id(&args.issue_id).and_then(|layout| {
report::recall(
&layout,
&args.issue_id,
args.depth.unwrap_or(1),
args.excerpts.unwrap_or(false),
)
}))
}
#[tool(
description = "Weigh an issue's ballots by who the group listens to (DeGroot averaging over the configured trust graph). Reports the count and the weighted position side by side, each agent's social power, and the two ways there is no consensus to report: a trust graph with more than one closed group, or one that never settles. Use it before acting on what a plurality looks like. Set `children` to roll up over a plan's children instead: that answers whether an epic can close, and it reports the children row by row rather than averaging them, because a split child has no position to fold in and an unvoted child is absent rather than neutral.",
annotations(
title = "Weighted consensus",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_consensus(
&self,
Parameters(args): Parameters<ConsensusArgs>,
) -> Result<CallToolResult, McpError> {
let children = args.children.unwrap_or(false);
text(self.layout_for_id(&args.issue_id).and_then(|layout| {
if children {
report::plan_consensus(&layout, &args.issue_id)
} else {
report::consensus(&layout, &args.issue_id)
}
}))
}
#[tool(
description = "Every live claim, oldest first: who holds what issue, and for how long.",
annotations(title = "Live claims", read_only_hint = true, open_world_hint = false)
)]
async fn vissue_claims(
&self,
Parameters(args): Parameters<ClaimsArgs>,
) -> Result<CallToolResult, McpError> {
text(report::claims(
&self.layout,
args.holder.as_deref(),
args.project.as_deref(),
args.json.unwrap_or(false),
))
}
#[tool(
description = "Dated open work: deadlines and scheduled starts inside a horizon, overdue first.",
annotations(
title = "Dated open work",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_agenda(
&self,
Parameters(args): Parameters<AgendaArgs>,
) -> Result<CallToolResult, McpError> {
text(report::agenda(
&self.layout,
args.days.unwrap_or(14),
args.project.as_deref(),
))
}
#[tool(
description = "Fold an inbox org file: each unstamped `* TODO` heading becomes an issue and the heading is stamped with the id in place.",
annotations(
title = "Fold an inbox file",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn vissue_fold(
&self,
Parameters(args): Parameters<FoldArgs>,
) -> Result<CallToolResult, McpError> {
text({
let pref = self.router.route(&args.project);
ops::fold(&pref.layout, std::path::Path::new(&args.file), &pref.dir)
})
}
#[tool(
description = "Count issues, optionally filtered by project, state, or readiness.",
annotations(title = "Count issues", read_only_hint = true, open_world_hint = false)
)]
async fn vissue_count(
&self,
Parameters(args): Parameters<CountArgs>,
) -> Result<CallToolResult, McpError> {
text(report::count(
&self.layout,
args.project.as_deref(),
args.state.as_deref(),
args.ready.unwrap_or(false),
))
}
#[tool(
description = "Substring search over ids, titles, properties, and bodies.",
annotations(
title = "Search the corpus",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_search(
&self,
Parameters(args): Parameters<SearchArgs>,
) -> Result<CallToolResult, McpError> {
text(report::search(
&self.layout,
&args.query,
args.limit.unwrap_or(20),
))
}
#[tool(
description = "Explain bounded Org and lexical connections around an issue.",
annotations(
title = "Related issues",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_related(
&self,
Parameters(args): Parameters<RelatedArgs>,
) -> Result<CallToolResult, McpError> {
text(self.layout_for_id(&args.issue_id).and_then(|layout| {
report::related(
&layout,
&args.issue_id,
args.depth.unwrap_or(2),
args.limit.unwrap_or(20),
args.format.as_deref().unwrap_or("text"),
)
}))
}
#[tool(
description = "List issues whose PARENT property matches this id.",
annotations(
title = "Children of an issue",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_children(
&self,
Parameters(args): Parameters<IdArgs>,
) -> Result<CallToolResult, McpError> {
text(
self.layout_for_id(&args.issue_id)
.and_then(|layout| report::children(&layout, &args.issue_id)),
)
}
#[tool(
description = "List issues that refer to this id through any relation, or that cite this deed accession.",
annotations(title = "Backlinks", read_only_hint = true, open_world_hint = false)
)]
async fn vissue_backlinks(
&self,
Parameters(args): Parameters<IdArgs>,
) -> Result<CallToolResult, McpError> {
text(self.backlinks_text(&args.issue_id))
}
#[tool(
description = "Issues waiting on this id. Dependency hygiene alias for backlinks.",
annotations(
title = "Issues waiting on this",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_waiting_on(
&self,
Parameters(args): Parameters<IdArgs>,
) -> Result<CallToolResult, McpError> {
text(
self.layout_for_id(&args.issue_id)
.and_then(|layout| agent::waiting_on(&layout, &args.issue_id)),
)
}
#[tool(
description = "The first lines of an issue's file range, screened for secrets.",
annotations(title = "Body excerpt", read_only_hint = true, open_world_hint = false)
)]
async fn vissue_body_excerpt(
&self,
Parameters(args): Parameters<IdArgs>,
) -> Result<CallToolResult, McpError> {
text(
self.layout_for_id(&args.issue_id)
.and_then(|layout| agent::body_excerpt(&layout, &args.issue_id)),
)
}
#[tool(
description = "One issue's org text in full, untruncated, screened for secrets. Use this when handing an issue to someone as the thing to work from; body_excerpt is a capped preview.",
annotations(
title = "Full Org text",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_org(
&self,
Parameters(args): Parameters<IdArgs>,
) -> Result<CallToolResult, McpError> {
text(
self.layout_for_id(&args.issue_id)
.and_then(|layout| agent::org_text(&layout, &args.issue_id)),
)
}
#[tool(
description = "Children and blockers below an id, as ascii indent or Graphviz DOT.",
annotations(
title = "Tree below an id",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_tree(
&self,
Parameters(args): Parameters<TreeArgs>,
) -> Result<CallToolResult, McpError> {
text(self.layout_for_id(&args.issue_id).and_then(|layout| {
report::tree(
&layout,
&args.issue_id,
args.format.as_deref().unwrap_or("ascii"),
)
}))
}
#[tool(
description = "The blocker and parent graph as Graphviz DOT.",
annotations(title = "Graph as DOT", read_only_hint = true, open_world_hint = false)
)]
async fn vissue_graph(
&self,
Parameters(args): Parameters<ProjectArgs>,
) -> Result<CallToolResult, McpError> {
text(report::graph(&self.layout, args.project.as_deref()))
}
#[tool(
description = "A markdown roadmap of active and closed work.",
annotations(title = "Roadmap", read_only_hint = true, open_world_hint = false)
)]
async fn vissue_roadmap(
&self,
Parameters(args): Parameters<ProjectArgs>,
) -> Result<CallToolResult, McpError> {
text(report::roadmap(&self.layout, args.project.as_deref()))
}
#[tool(
description = "One JSON object per issue per line.",
annotations(
title = "Export as JSON lines",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_export(
&self,
Parameters(args): Parameters<ProjectArgs>,
) -> Result<CallToolResult, McpError> {
text(report::export(&self.layout, args.project.as_deref()))
}
#[tool(
description = "Validate the corpus: dangling edges, bad dates, duplicate ids.",
annotations(
title = "Validate the corpus",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_check(&self) -> Result<CallToolResult, McpError> {
text(report::check(&self.layout).map(|r| r.text))
}
#[tool(
description = "Rewrite files onto the Org / ELPA / vissue property split. Dry-run by default when dry_run is true.",
annotations(
title = "Rewrite property split",
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn vissue_normalize(
&self,
Parameters(args): Parameters<NormalizeArgs>,
) -> Result<CallToolResult, McpError> {
text(ops::normalize(
&self.layout,
args.project.as_deref(),
args.dry_run.unwrap_or(false),
))
}
#[tool(
description = "Checklist for agents and CI: stalled claims plus corpus validation.",
annotations(
title = "Hygiene checklist",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_hygiene(
&self,
Parameters(args): Parameters<HygieneArgs>,
) -> Result<CallToolResult, McpError> {
text(agent::hygiene(&self.layout, args.stale_days))
}
#[tool(
description = "Content digest of the corpus: combined, per-project, issue count, generation.",
annotations(
title = "Corpus digest",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_digest(
&self,
Parameters(args): Parameters<DigestArgs>,
) -> Result<Json<vissue_core::digest::CorpusDigest>, McpError> {
structured(vissue_core::digest::corpus_digest(
&self.layout,
&args.projects.unwrap_or_default(),
))
}
#[tool(
description = "Check whether a mirror file's SYNC stamp still matches the tracker.",
annotations(
title = "Check a mirror stamp",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_mirror_check(
&self,
Parameters(args): Parameters<MirrorCheckArgs>,
) -> Result<CallToolResult, McpError> {
text(
mirror::check(
&self.layout,
std::path::Path::new(&args.path),
&args.projects.unwrap_or_default(),
)
.map(|v| v.report),
)
}
#[tool(
description = "Render a read-only projection of selected projects.",
annotations(
title = "Render a projection",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_mirror(
&self,
Parameters(args): Parameters<MirrorArgs>,
) -> Result<CallToolResult, McpError> {
let format = match Format::parse(args.format.as_deref().unwrap_or("org")) {
Ok(f) => f,
Err(e) => return Err(McpError::invalid_params(format!("{e:#}"), None)),
};
text(mirror::render(
&self.layout,
&args.projects.unwrap_or_default(),
format,
args.state.as_deref(),
))
}
#[tool(
description = "Change events with a sequence above `since`, plus the current generation.",
annotations(
title = "Change events",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_events(
&self,
Parameters(args): Parameters<EventsArgs>,
) -> Result<CallToolResult, McpError> {
text(events::since_report(
&self.layout,
args.since.unwrap_or(0),
args.limit.unwrap_or(50),
))
}
#[tool(
description = "Append a manual event, waking pollers without editing an issue.",
annotations(
title = "Append an event",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = false
)
)]
async fn vissue_ping(
&self,
Parameters(args): Parameters<PingArgs>,
) -> Result<CallToolResult, McpError> {
text(events::ping_report(&self.layout, args.detail.as_deref()))
}
#[tool(
description = "The generation counter. Compare against the last value seen.",
annotations(
title = "Generation counter",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_gen(&self) -> Result<CallToolResult, McpError> {
text(Ok::<_, vissue_core::error::Error>(format!(
"{}\n",
events::generation(&self.layout)
)))
}
#[tool(
description = "Report the server version and the resolved root and prefix.",
annotations(
title = "Server identity",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_identity(&self) -> Result<CallToolResult, McpError> {
text(Ok::<_, vissue_core::error::Error>(identity_report(
&self.layout,
&self.router,
)))
}
#[tool(
description = "Transitive blocker ancestors, bounded by hop depth.",
annotations(
title = "Blocker ancestors",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_ancestors(
&self,
Parameters(args): Parameters<DepthArgs>,
) -> Result<CallToolResult, McpError> {
text(
self.layout_for_id(&args.issue_id).and_then(|layout| {
report::ancestors(&layout, &args.issue_id, args.depth.unwrap_or(3))
}),
)
}
#[tool(
description = "Issues transitively waiting on this id, bounded by hop depth.",
annotations(
title = "Transitive impact",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_impact(
&self,
Parameters(args): Parameters<DepthArgs>,
) -> Result<CallToolResult, McpError> {
text(
self.layout_for_id(&args.issue_id).and_then(|layout| {
report::impact(&layout, &args.issue_id, args.depth.unwrap_or(3))
}),
)
}
#[tool(
description = "Cycles in the blocker graph, or a line saying there are none.",
annotations(
title = "Blocker cycles",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_cycles(&self) -> Result<CallToolResult, McpError> {
text(report::cycles(&self.layout))
}
#[tool(
description = "Pack a slice of the tracker into a directory somebody else can open: the issues named, everything they stand on, and the deed accessions their work produced. Deeds are named and not enclosed, because only the deed store can vouch for them; fill them with `deedar export --into <dir>/data/deeds -` and then seal. Reports what was packed and what came along that was not asked for.",
annotations(
title = "Pack a satchel",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn vissue_satchel(
&self,
Parameters(args): Parameters<SatchelArgs>,
) -> Result<CallToolResult, McpError> {
let slice = vissue_core::satchel::Slice {
projects: args.projects.unwrap_or_default(),
issues: args.issues.unwrap_or_default(),
};
text(
vissue_core::satchel::pack(&self.layout, &slice, std::path::Path::new(&args.out))
.map(|report| report.render()),
)
}
#[tool(
description = "Re-manifest a satchel over everything now in its payload. Run this after the deed store has filled in the deeds, because the manifest written at pack time covers only what the tracker wrote.",
annotations(
title = "Seal a satchel",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn vissue_satchel_seal(
&self,
Parameters(args): Parameters<SatchelDirArgs>,
) -> Result<CallToolResult, McpError> {
text(
vissue_core::satchel::seal(std::path::Path::new(&args.dir))
.map(|report| report.render()),
)
}
#[tool(
description = "Check a satchel that arrived: every file the manifest names is present and unchanged, and nothing in the payload is unaccounted for. Fails with what is wrong rather than a verdict.",
annotations(
title = "Check a satchel",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_satchel_verify(
&self,
Parameters(args): Parameters<SatchelDirArgs>,
) -> Result<CallToolResult, McpError> {
text(
vissue_core::satchel::verify(std::path::Path::new(&args.dir))
.map(|report| report.render()),
)
}
#[tool(
description = "Move an issue heading to another project file.",
annotations(
title = "Move to another project",
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn vissue_refile(
&self,
Parameters(args): Parameters<RefileArgs>,
) -> Result<CallToolResult, McpError> {
text(self.layout_for_id(&args.issue_id).and_then(|layout| {
let dest = self.router.route(&args.to);
ops::refile_to(&layout, &args.issue_id, &dest.layout, &dest.dir)
}))
}
#[tool(
description = "Block until the generation counter passes last, or until an issue is DONE or CANCELLED when until_terminal and id are set.",
annotations(
title = "Wait for a change",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_wait(
&self,
Parameters(args): Parameters<WaitArgs>,
) -> Result<CallToolResult, McpError> {
if args.until_terminal.unwrap_or(false) {
let Some(id) = args.id.as_deref() else {
return Err(McpError::invalid_params(
"--until-terminal requires id",
None,
));
};
return text(
events::wait_until_terminal(
&match self.layout_for_id(id) {
Ok(layout) => layout,
Err(e) => return text(Err(e)),
},
id,
args.poll_ms.unwrap_or(200),
args.timeout_ms.unwrap_or(10_000),
)
.map(|outcome| match outcome {
events::TerminalWait::Done { generation } => {
format!("DONE {generation}\n")
}
events::TerminalWait::Cancelled { generation } => {
format!("CANCELLED {generation}\n")
}
events::TerminalWait::Timeout { generation, state } => {
format!("TIMEOUT {state} {generation}\n")
}
}),
);
}
let last = args.last.unwrap_or(0);
text(
events::wait_generation(
&self.layout,
last,
args.poll_ms.unwrap_or(200),
args.timeout_ms.unwrap_or(10_000),
)
.map(|generation| {
let timed_out = if generation <= last { " timeout" } else { "" };
format!("{generation}{timed_out}\n")
}),
)
}
#[tool(
description = "The identity a claim would record.",
annotations(
title = "Claiming identity",
read_only_hint = true,
open_world_hint = false
)
)]
async fn vissue_whoami(&self) -> Result<CallToolResult, McpError> {
text(Ok::<_, vissue_core::error::Error>(format!(
"{}\n",
vissue_core::config::identity(&self.layout)
)))
}
}
fn create_routed(
router: &Router,
project: &str,
title: &str,
opts: CreateOpts<'_>,
) -> vissue_core::Result<String> {
let pref = router.route(project);
let twins = router.extra_id_paths_for(&pref.dir);
let opts = CreateOpts {
extra_id_paths: &twins,
..opts
};
ops::create(&pref.layout, &pref.dir, title, opts)
}
fn issue_rows_routed(
router: &Router,
project: Option<&str>,
state: Option<&str>,
ready_only: bool,
) -> vissue_core::Result<Vec<IssueRow>> {
if let Some(p) = project {
let pref = router.route(p);
return agent::issues_rows(&pref.layout, Some(&pref.dir), state, ready_only);
}
let mut rows = Vec::new();
for pref in router.visible_projects()? {
rows.extend(agent::issues_rows(
&pref.layout,
Some(&pref.dir),
state,
ready_only,
)?);
}
Ok(rows)
}
fn identity_report(layout: &Layout, router: &Router) -> String {
let mut out = format!(
"vissue-mcp {}\nprotocol: {}\nroot: {}\nprefix: {}\nroot={}\nprefix={}\n",
env!("CARGO_PKG_VERSION"),
vissue_core::org::PROTOCOL_VERSION,
layout.root().display(),
layout.prefix(),
layout.root().display(),
layout.prefix()
);
if let Ok(prefs) = router.visible_projects() {
for pref in prefs {
if pref.key == pref.dir
&& pref.layout.root() == layout.root()
&& pref.layout.prefix() == layout.prefix()
{
continue;
}
out.push_str(&format!(
"route: {} -> {} {} {}\n",
pref.key,
pref.layout.root().display(),
pref.layout.prefix(),
pref.dir
));
}
}
out
}
/// The scheme issues are addressable under.
const SCHEME: &str = "vissue";
/// Org text, which is what every resource here is.
const ORG: &str = "text/x-org";
impl VissueServer {
/// One issue's org text, addressed rather than queried.
fn read_issue(&self, id: &str) -> Result<String, McpError> {
self.layout_for_id(id)
.and_then(|layout| agent::org_text(&layout, id))
.map_err(|e| McpError::resource_not_found(format!("{e}"), None))
}
/// Ids matching what has been typed, by id prefix then by title.
///
/// Split out from the protocol handler so the matching can be tested
/// without standing up a session.
fn complete_issue_ids(&self, typed: &str) -> Result<Vec<String>, McpError> {
let typed = typed.to_ascii_lowercase();
let mut hit: Vec<String> = Vec::new();
for pref in self
.router
.visible_projects()
.map_err(|e| McpError::internal_error(format!("{e}"), None))?
{
let rows = agent::issues_rows(&pref.layout, Some(&pref.dir), None, false)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
for row in rows {
let id = row.id.to_ascii_lowercase();
if typed.is_empty()
|| id.starts_with(&typed)
|| row.title.to_ascii_lowercase().contains(&typed)
{
hit.push(row.id);
}
}
}
hit.sort_unstable();
hit.dedup();
Ok(hit)
}
/// One project's issues, as the org a reader would open.
fn read_project(&self, project: &str) -> Result<String, McpError> {
let pref = self.router.route(project);
mirror::render(
&pref.layout,
std::slice::from_ref(&pref.dir),
Format::Org,
None,
)
.map_err(|e| McpError::resource_not_found(format!("{e}"), None))
}
}
#[tool_handler]
impl ServerHandler for VissueServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.enable_completions()
.build(),
)
.with_server_info(Implementation::new("vissue", env!("CARGO_PKG_VERSION")))
.with_instructions(
"An issue is addressable at vissue://issue/<id> and a project at \
vissue://project/<name>. Read those rather than calling a tool when \
what you want is the text; the tools answer questions the text does \
not, like what is ready or what blocks what.",
)
}
/// The projects, which are the resources that exist without being named.
///
/// Issues are not listed. There are hundreds and they arrive through a
/// template instead: a list a client has to page through to find one id is
/// worse than a pattern it can fill in, and the ids come back from every
/// tool that answers a question.
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ListResourcesResult, McpError> {
let projects = self
.router
.visible_projects()
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
Ok(ListResourcesResult::with_all_items(
projects
.into_iter()
.map(|p| {
let mut resource =
Resource::new(format!("{SCHEME}://project/{}", p.key), p.key.clone());
resource.title = Some(format!("{} issues", p.key));
resource.description = Some(format!("Every issue in the {} project.", p.key));
resource.mime_type = Some(ORG.to_string());
resource
})
.collect(),
))
}
/// The pattern one issue is addressed by.
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ListResourceTemplatesResult, McpError> {
let mut template =
ResourceTemplate::new(format!("{SCHEME}://issue/{{id}}"), "issue".to_string());
template.title = Some("One issue".to_string());
template.description =
Some("The org text of one issue, by id, with secrets screened out.".to_string());
template.mime_type = Some(ORG.to_string());
Ok(ListResourceTemplatesResult::with_all_items(vec![template]))
}
/// Fill in the id a resource template asks for.
///
/// The spec completes resource template and prompt arguments, not tool
/// arguments, which is the right shape here anyway: the template is the
/// one place a caller has to produce an id from nothing. Every tool that
/// answers a question hands ids back, so a caller working from an answer
/// already has them; a caller starting from the template does not.
///
/// Matching is a prefix on the id, then anywhere in the title, because a
/// person completing an issue remembers what it was about more often than
/// what it was called. Capped at the hundred the spec allows, with
/// `has_more` set so a client can say the list is a window rather than the
/// answer.
async fn complete(
&self,
request: CompleteRequestParams,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<CompleteResult, McpError> {
let Reference::Resource(template) = &request.r#ref else {
return Ok(CompleteResult::default());
};
if !template.uri.starts_with(&format!("{SCHEME}://issue/")) || request.argument.name != "id"
{
return Ok(CompleteResult::default());
}
let mut hit = self.complete_issue_ids(&request.argument.value)?;
let total = hit.len();
hit.truncate(CompletionInfo::MAX_VALUES);
let more = total > hit.len();
let mut completion =
CompletionInfo::new(hit).map_err(|e| McpError::internal_error(e, None))?;
completion.total = u32::try_from(total).ok();
completion.has_more = Some(more);
Ok(CompleteResult::new(completion))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ReadResourceResponse, McpError> {
let uri = request.uri.clone();
let rest = uri.strip_prefix(&format!("{SCHEME}://")).ok_or_else(|| {
McpError::resource_not_found(format!("not a {SCHEME} uri: {uri}"), None)
})?;
let text = match rest.split_once('/') {
Some(("issue", id)) if !id.is_empty() => self.read_issue(id)?,
Some(("project", project)) if !project.is_empty() => self.read_project(project)?,
_ => {
return Err(McpError::resource_not_found(
format!("{uri} names neither an issue nor a project"),
None,
));
}
};
Ok(
ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
uri,
mime_type: Some(ORG.to_string()),
text,
meta: None,
}])
.into(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use vissue_core::config::DEFAULT_PREFIX;
/// The tools an agent uses to work a node: recall before, deed after.
///
/// Over the tool surface rather than the library, because this is the one an
/// agent actually reaches for, and a working set it cannot ask for is a
/// working set it will not use.
#[tokio::test]
async fn the_working_set_reaches_the_tool_surface() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
std::fs::create_dir_all(layout.projects_dir()).unwrap();
ops::create(
&layout,
"keys",
"catalog the actions",
CreateOpts::default(),
)
.unwrap();
ops::create(&layout, "keys", "write the schema", CreateOpts::default()).unwrap();
let id_of = |title: &str| -> String {
vissue_core::store::load_all(&layout)
.unwrap()
.into_iter()
.find(|(_, h)| h.title == title)
.map(|(_, h)| h.id)
.expect("issue")
};
let first = id_of("catalog the actions");
let second = id_of("write the schema");
ops::update(&layout, &second, None, None, Some(&first), None).unwrap();
let server = VissueServer::with_layout(layout.clone());
let cited = server
.vissue_deed(Parameters(DeedArgs {
issue_id: first.clone(),
add: Some(vec!["deed-file-catalog".into()]),
remove: None,
}))
.await
.unwrap();
assert_eq!(cited.is_error, Some(false));
let recalled = server
.vissue_recall(Parameters(RecallArgs {
issue_id: second.clone(),
depth: None,
excerpts: None,
}))
.await
.unwrap();
assert_eq!(recalled.is_error, Some(false));
let rendered = format!("{:?}", recalled.content);
assert!(
rendered.contains("deed-file-catalog"),
"the input's product is what the next unit opens: {rendered}"
);
// A citation nothing can resolve is an error the agent sees, not a
// value the heading quietly keeps.
let refused = server
.vissue_deed(Parameters(DeedArgs {
issue_id: first,
add: Some(vec!["/tmp/note.md".into()]),
remove: None,
}))
.await;
let message = refused.expect_err("a path is not an accession").message;
assert!(message.contains("not a deed accession"), "{message}");
}
/// The consensus tool answers on an unconfigured tracker, where it is the
/// tally as shares, and says so.
#[tokio::test]
async fn the_consensus_tool_answers_without_trust_configured() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
std::fs::create_dir_all(layout.projects_dir()).unwrap();
ops::create(&layout, "api", "ship it?", CreateOpts::default()).unwrap();
let id = vissue_core::store::load_all(&layout).unwrap()[0]
.1
.id
.clone();
for (agent, choice) in [("a", "ship"), ("b", "ship"), ("c", "hold")] {
ops::vote(&layout, &id, Some(choice), agent).unwrap();
}
let server = VissueServer::with_layout(layout);
let weighed = server
.vissue_consensus(Parameters(ConsensusArgs {
issue_id: id,
children: None,
}))
.await
.unwrap();
assert_eq!(weighed.is_error, Some(false));
let rendered = format!("{:?}", weighed.content);
assert!(rendered.contains("trust default"), "{rendered}");
assert!(rendered.contains("holds: ship"), "{rendered}");
}
#[tokio::test]
async fn tools_answer_against_a_temporary_layout() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
std::fs::create_dir_all(layout.projects_dir()).unwrap();
ops::create(&layout, "sample", "first", CreateOpts::default()).unwrap();
let server = VissueServer::with_layout(layout);
// The rows come back as rows. Reading a field is a stronger check
// than an error flag: it fails if the shape moves, not only if the
// call does.
let listed = server
.vissue_list(Parameters(ListArgs {
project: None,
state: None,
}))
.await
.unwrap();
assert_eq!(listed.0.len(), 1, "{:?}", listed.0);
assert_eq!(listed.0[0].project, "sample");
assert_eq!(listed.0[0].state, "TODO");
let counted = server
.vissue_count(Parameters(CountArgs {
project: None,
state: None,
ready: Some(true),
}))
.await
.unwrap();
assert_eq!(counted.is_error, Some(false));
}
/// The tools that write, exercised in the order an agent uses them.
///
/// The read-only surface is covered above; the create/update/claim/note
/// path was not, and it is the half that changes the corpus.
#[tokio::test]
async fn the_write_tools_carry_their_arguments_through_to_the_file() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
std::fs::create_dir_all(layout.projects_dir()).unwrap();
let server = VissueServer::with_layout(layout.clone());
let made = server
.vissue_create(Parameters(CreateArgs {
project: "atlas".into(),
title: "Rotate the signing key".into(),
priority: Some("A".into()),
issue_type: Some("chore".into()),
tags: Some("ops,security".into()),
parent: None,
body: Some("The old one expires this quarter.".into()),
deadline: Some("[2026-06-30]".into()),
scheduled: None,
}))
.await
.unwrap();
assert_eq!(made.is_error, Some(false));
let file = std::fs::read_to_string(layout.project_issues_path("atlas")).unwrap();
assert!(
file.contains("DEADLINE") && file.contains("2026-06-30"),
"the tool accepted a deadline and did not write it: {file}"
);
assert!(file.contains("Rotate the signing key"), "{file}");
assert!(file.contains("[#A]"), "priority not carried: {file}");
assert!(file.contains(":TYPE: chore"), "{file}");
assert!(
file.contains("expires this quarter"),
"body missing: {file}"
);
assert!(
file.contains(":ops:") && file.contains(":security:"),
"{file}"
);
let id = file
.lines()
.find_map(|l| l.trim().strip_prefix(":ID:"))
.map(|s| s.trim().to_string())
.expect("an id");
// Claim, then note: neither may disturb the other's stamp.
assert_eq!(
server
.vissue_claim(Parameters(ClaimArgs {
issue_id: id.clone(),
force: None,
}))
.await
.unwrap()
.is_error,
Some(false)
);
assert_eq!(
server
.vissue_note(Parameters(NoteArgs {
issue_id: id.clone(),
text: "waiting on the vault rotation window".into(),
}))
.await
.unwrap()
.is_error,
Some(false)
);
// A written report goes into the body, where markdown is safe.
assert_eq!(
server
.vissue_append(Parameters(AppendArgs {
issue_id: id.clone(),
text: "## What changed\n\n* rotated the key\n".into(),
}))
.await
.unwrap()
.is_error,
Some(false)
);
let after = std::fs::read_to_string(layout.project_issues_path("atlas")).unwrap();
assert!(after.contains("## What changed"), "{after}");
assert!(after.contains(":CLAIMED_BY:"), "{after}");
assert!(after.contains("vault rotation window"), "{after}");
// Closing reports the change; the tool surfaces hints alongside it.
let closed = server
.vissue_update(Parameters(UpdateArgs {
issue_id: id.clone(),
state: Some("DONE".into()),
priority: Some("C".into()),
block: None,
unblock: None,
if_state: None,
if_gen: None,
}))
.await
.unwrap();
assert_eq!(closed.is_error, Some(false));
let done = std::fs::read_to_string(layout.project_issues_path("atlas")).unwrap();
assert!(done.contains("DONE"), "{done}");
assert!(done.contains("[#C]"), "{done}");
}
#[tokio::test]
async fn a_write_tool_reports_an_unknown_id_as_an_error() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
std::fs::create_dir_all(layout.projects_dir()).unwrap();
let server = VissueServer::with_layout(layout);
let err = server
.vissue_note(Parameters(NoteArgs {
issue_id: "atlas-zzzz".into(),
text: "into the void".into(),
}))
.await
.unwrap_err();
assert!(format!("{err:?}").contains("atlas-zzzz"), "{err:?}");
}
#[tokio::test]
async fn an_unknown_mirror_format_is_an_invalid_parameter() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
let server = VissueServer::with_layout(layout);
let err = server
.vissue_mirror(Parameters(MirrorArgs {
projects: None,
format: Some("pdf".into()),
state: None,
}))
.await
.unwrap_err();
assert!(format!("{err:?}").contains("pdf"), "{err:?}");
}
/// Every tool says whether it reads or writes, and a new one cannot ship
/// without saying.
///
/// The hints are what a client uses to decide whether a call is safe to
/// make on its own, retry, or batch. A surface where the tool that lists
/// projects and the tool that rewrites every file look alike gives a
/// caller nothing to reason with, and forty-six unannotated tools is a
/// surface that says nothing forty-six times.
///
/// The read-only set is written out rather than derived, so adding a tool
/// fails here until somebody decides which side it is on.
#[test]
fn every_tool_declares_what_it_does_to_the_tracker() {
const READS: &[&str] = &[
"vissue_agenda",
"vissue_ancestors",
"vissue_backlinks",
"vissue_body_excerpt",
"vissue_check",
"vissue_children",
"vissue_claims",
"vissue_consensus",
"vissue_count",
"vissue_cycles",
"vissue_digest",
"vissue_events",
"vissue_export",
"vissue_gen",
"vissue_graph",
"vissue_hygiene",
"vissue_identity",
"vissue_impact",
"vissue_list",
"vissue_mirror",
"vissue_mirror_check",
"vissue_org",
"vissue_projects",
"vissue_ready",
"vissue_recall",
"vissue_related",
"vissue_roadmap",
"vissue_satchel_verify",
"vissue_search",
"vissue_show",
"vissue_tree",
"vissue_wait",
"vissue_waiting_on",
"vissue_whoami",
];
let tools = VissueServer::tool_router().list_all();
assert!(tools.len() >= READS.len(), "{} tools", tools.len());
let mut reads: Vec<&str> = Vec::new();
for tool in &tools {
let hints = tool
.annotations
.as_ref()
.unwrap_or_else(|| panic!("{} carries no annotations", tool.name));
assert!(
hints.title.as_ref().is_some_and(|t| !t.is_empty()),
"{} has no title",
tool.name
);
// Everything here reads files under one root. A tool that reached
// outside it would be a different kind of thing and should say so.
assert_eq!(
hints.open_world_hint,
Some(false),
"{} claims an open world",
tool.name
);
match hints.read_only_hint {
Some(true) => reads.push(&tool.name),
Some(false) => {
// The other two hints are meaningful only for a writer,
// and a writer that leaves them unset takes the spec's
// defaults: destructive, not idempotent. Say it instead.
assert!(
hints.destructive_hint.is_some(),
"{} does not say whether it is destructive",
tool.name
);
assert!(
hints.idempotent_hint.is_some(),
"{} does not say whether it is idempotent",
tool.name
);
}
None => panic!("{} does not say whether it writes", tool.name),
}
}
reads.sort_unstable();
assert_eq!(reads, READS, "the read-only set moved");
}
/// An issue is a thing with an identity and text, which is what a
/// resource is. A question about issues is what a tool is for.
#[tokio::test]
async fn an_issue_is_addressable_without_a_tool_call() {
let root =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixture_vault");
let server = VissueServer::with_layout(Layout::new(&root, DEFAULT_PREFIX));
let one = server.read_issue("atlas-2c3d").expect("the issue reads");
assert!(one.contains("atlas-2c3d"), "{one}");
let project = server.read_project("atlas").expect("the project reads");
assert!(project.contains("atlas-2c3d"), "{project}");
// A uri that names nothing is not found rather than empty, which is
// the same distinction the tracker root makes.
assert!(server.read_issue("atlas-nosuch").is_err());
}
/// The template's id completes from the corpus, by id and by title.
///
/// A caller working from any tool's answer already has ids. A caller
/// starting from the template has nothing, which is the case this exists
/// for, and remembering what an issue was about is more common than
/// remembering its suffix.
#[tokio::test]
async fn the_issue_template_completes_its_id() {
let root =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixture_vault");
let server = VissueServer::with_layout(Layout::new(&root, DEFAULT_PREFIX));
let ids = server.complete_issue_ids("atlas-2c").expect("completes");
assert!(ids.contains(&"atlas-2c3d".to_string()), "{ids:?}");
// Empty offers the corpus rather than nothing, which is what a client
// opening a picker wants.
let all = server.complete_issue_ids("").expect("completes");
assert!(all.len() >= ids.len(), "{} vs {}", all.len(), ids.len());
// A suffix nobody has completes to nothing rather than everything.
assert!(
server
.complete_issue_ids("zzzz-nope")
.expect("completes")
.is_empty()
);
}
/// The tools that answer with data publish the shape of it.
///
/// A schema is what lets a caller check a reply rather than hope, and it
/// is derived from the type that is returned, so this also fails if the
/// core stops deriving it and the tool silently goes back to handing over
/// a string.
#[test]
fn the_tools_that_return_data_publish_its_shape() {
const SHAPED: &[&str] = &[
"vissue_digest",
"vissue_list",
"vissue_ready",
"vissue_show",
];
let tools = VissueServer::tool_router().list_all();
let mut shaped: Vec<&str> = tools
.iter()
.filter(|t| t.output_schema.is_some())
.map(|t| t.name.as_ref())
.collect();
shaped.sort_unstable();
assert_eq!(shaped, SHAPED, "the tools answering with data moved");
let rows = tools
.iter()
.find(|t| t.name == "vissue_list")
.and_then(|t| t.output_schema.clone())
.expect("a schema for the rows");
// An array of issue rows, and the row names the fields a board paints.
let rendered = serde_json::to_string(&rows).expect("schema serializes");
for field in ["id", "state", "priority", "title", "project", "blocked_by"] {
assert!(rendered.contains(field), "{field} is not in {rendered}");
}
}
#[tokio::test]
async fn read_only_tools_cover_the_fixture_tracker_surface() {
let root =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixture_vault");
let server = VissueServer::with_layout(Layout::new(&root, DEFAULT_PREFIX));
assert!(
!server
.vissue_projects()
.await
.unwrap()
.is_error
.unwrap_or(false)
);
let listed = server
.vissue_list(Parameters(ListArgs {
project: Some("atlas".into()),
state: Some("TODO".into()),
}))
.await
.unwrap();
assert!(listed.0.iter().all(|row| row.state == "TODO"));
let ready = server
.vissue_ready(Parameters(ProjectArgs {
project: Some("atlas".into()),
}))
.await
.unwrap();
assert!(ready.0.iter().all(|row| row.blocked_by.is_empty()));
let shown = server
.vissue_show(Parameters(IdArgs {
issue_id: "atlas-2c3d".into(),
}))
.await
.unwrap();
assert_eq!(shown.0.id, "atlas-2c3d");
assert!(
!server
.vissue_claims(Parameters(ClaimsArgs {
holder: None,
project: Some("atlas".into()),
json: Some(true),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_agenda(Parameters(AgendaArgs {
days: Some(7),
project: Some("atlas".into()),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_search(Parameters(SearchArgs {
query: "fixture".into(),
limit: Some(5),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_related(Parameters(RelatedArgs {
issue_id: "atlas-1a2b".into(),
depth: Some(2),
limit: Some(5),
format: Some("org".into()),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_children(Parameters(IdArgs {
issue_id: "atlas-1a2b".into(),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_backlinks(Parameters(IdArgs {
issue_id: "atlas-1a2b".into(),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_waiting_on(Parameters(IdArgs {
issue_id: "atlas-1a2b".into(),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_org(Parameters(IdArgs {
issue_id: "atlas-2c3d".into(),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_body_excerpt(Parameters(IdArgs {
issue_id: "atlas-2c3d".into(),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_tree(Parameters(TreeArgs {
issue_id: "atlas-1a2b".into(),
format: Some("ascii".into()),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_graph(Parameters(ProjectArgs {
project: Some("atlas".into()),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_roadmap(Parameters(ProjectArgs {
project: Some("atlas".into()),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_export(Parameters(ProjectArgs {
project: Some("atlas".into()),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_check()
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_hygiene(Parameters(HygieneArgs {
stale_days: Some(30)
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
let digested = server
.vissue_digest(Parameters(DigestArgs {
projects: Some(vec!["atlas".into()]),
}))
.await
.unwrap();
assert_eq!(digested.0.combined.len(), 16, "{}", digested.0.combined);
assert!(
!server
.vissue_mirror(Parameters(MirrorArgs {
projects: Some(vec!["atlas".into()]),
format: Some("markdown".into()),
state: Some("TODO".into()),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_events(Parameters(EventsArgs {
since: Some(0),
limit: Some(10),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(!server.vissue_gen().await.unwrap().is_error.unwrap_or(false));
assert!(
!server
.vissue_identity()
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_ancestors(Parameters(DepthArgs {
issue_id: "atlas-3e4f".into(),
depth: Some(2),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_impact(Parameters(DepthArgs {
issue_id: "atlas-1a2b".into(),
depth: Some(2),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_cycles()
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_whoami()
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_wait(Parameters(WaitArgs {
last: Some(0),
id: None,
until_terminal: None,
poll_ms: Some(10),
timeout_ms: Some(30),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
assert!(
!server
.vissue_wait(Parameters(WaitArgs {
last: None,
id: Some("atlas-4g5h".into()),
until_terminal: Some(true),
poll_ms: Some(10),
timeout_ms: Some(200),
}))
.await
.unwrap()
.is_error
.unwrap_or(false)
);
let info = server.get_info();
assert!(info.capabilities.tools.is_some());
}
}