use rmcp::{
ErrorData as McpError, handler::server::wrapper::Parameters, model::*, prompt, prompt_router,
};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::server::VissueServer;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SliceArgs {
pub projects: Option<String>,
pub issues: Option<String>,
pub out: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ReadyArgs {
pub project: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct IssueArgs {
pub issue: String,
}
fn asked(text: String) -> Vec<PromptMessage> {
vec![PromptMessage::new_text(Role::User, text)]
}
#[prompt_router(vis = "pub(crate)")]
impl VissueServer {
#[prompt(name = "pack_a_slice")]
pub async fn pack_a_slice_prompt(
&self,
Parameters(args): Parameters<SliceArgs>,
) -> Result<Vec<PromptMessage>, McpError> {
let projects = args.projects.unwrap_or_default();
let issues = args.issues.unwrap_or_default();
if projects.trim().is_empty() && issues.trim().is_empty() {
return Err(McpError::invalid_params(
"a slice that names nothing is not a slice: give projects, issues, or both",
None,
));
}
let out = args.out.unwrap_or_else(|| "./satchel".into());
let named = match (projects.trim(), issues.trim()) {
("", one) => format!("issues {one}"),
(one, "") => format!("projects {one}"),
(p, i) => format!("projects {p} and issues {i}"),
};
Ok(asked(format!(
"Pack a handover of {named} into {out}.\n\
\n\
Four stores hold four different things and the bag needs all of them, so\n\
the order matters:\n\
\n\
1. `vissue_satchel` with what was named. It walks the closure first: an\n\
issue whose blockers are absent is a task with no account of why it is\n\
not done, and a child with no parent is a task with no reason. What\n\
comes back names the deed accessions the issues cite, and that list is\n\
the only thing the other two stores need from the tracker.\n\
2. The pack's atoms and the deed store's bytes, both keyed by that list.\n\
Deeds carry an inclusion proof; atoms do not, and that asymmetry is\n\
real rather than an omission.\n\
3. `vissue_satchel_seal`, after the other two have written, so the\n\
manifest covers the whole payload rather than the half the tracker\n\
wrote. Sealing first proves only what was enumerated first.\n\
4. Sign the manifest. A receiver who recomputes digests from the bag they\n\
were handed is checking the bag against itself; the signature is what\n\
makes it a statement by somebody.\n\
\n\
Then `vissue_satchel_verify` and report what it says it did not check,\n\
not only that it passed."
)))
}
#[prompt(name = "pick_up_work")]
pub async fn pick_up_work_prompt(
&self,
Parameters(args): Parameters<ReadyArgs>,
) -> Result<Vec<PromptMessage>, McpError> {
let scope = args
.project
.filter(|p| !p.trim().is_empty())
.map_or_else(|| "every project".to_string(), |p| format!("project {p}"));
Ok(asked(format!(
"Find the next thing worth doing in {scope} and pick it up.\n\
\n\
`vissue_ready` rather than `vissue_list`: ready is TODO or STARTED with no\n\
open blocker, which is a different set from open, and working the second\n\
is how a blocked task gets started twice.\n\
\n\
Before claiming, read the issue and `vissue_recall` it. Recall answers what\n\
the work stands on and what its inputs produced, which is the half of the\n\
context the issue body does not carry.\n\
\n\
Claim it, and say so in one line: a claim is a statement to whoever else is\n\
holding a seat, not a private note. Then do the work, cite what it produced\n\
with `vissue_deed`, and close it. An issue closed with no deed cited is a\n\
claim the tracker cannot stand behind."
)))
}
#[prompt(name = "check_citations")]
pub async fn check_citations_prompt(
&self,
Parameters(args): Parameters<IssueArgs>,
) -> Result<Vec<PromptMessage>, McpError> {
let issue = args.issue;
Ok(asked(format!(
"Check the deeds {issue} cites, and say what still holds.\n\
\n\
`vissue_recall {issue}` gives the accessions. Three questions follow and\n\
they are not the same question:\n\
\n\
- does the deed store still answer for the bytes it published. A deed that\n\
was deleted leaves every signature over it perfectly good; only the log\n\
says one is missing.\n\
- is the cited deed still the tip, or has a later take superseded it. A\n\
citation that resolves and is stale is worse than one that fails, because\n\
nothing complains.\n\
- what else stands on anything that moved. `vissue_backlinks` on the\n\
accession walks it, and the answer is the blast radius rather than one\n\
issue's problem.\n\
\n\
Report the accession, which question it failed, and what waits on it. An\n\
issue whose citations all hold gets one line saying so."
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use vissue_core::config::{DEFAULT_PREFIX, Layout};
fn server() -> (tempfile::TempDir, VissueServer) {
let dir = tempfile::tempdir().expect("tempdir");
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
std::fs::create_dir_all(layout.projects_dir()).expect("dirs");
let server = VissueServer::with_layout(layout);
(dir, server)
}
#[tokio::test]
async fn every_prompt_renders_from_what_it_declares() {
let (_dir, server) = server();
let declared = VissueServer::prompt_router().list_all();
assert_eq!(declared.len(), 3, "{declared:?}");
for prompt in &declared {
assert!(
prompt.description.as_ref().is_some_and(|d| !d.is_empty()),
"{} carries no description",
prompt.name
);
let arguments = prompt
.arguments
.as_ref()
.unwrap_or_else(|| panic!("{} declares no arguments", prompt.name));
assert!(!arguments.is_empty(), "{}", prompt.name);
}
let mut names: Vec<&str> = declared.iter().map(|p| p.name.as_str()).collect();
names.sort_unstable();
assert_eq!(names, ["check_citations", "pack_a_slice", "pick_up_work"]);
let packed = server
.pack_a_slice_prompt(Parameters(SliceArgs {
projects: Some("keys".into()),
issues: None,
out: Some("/tmp/bag".into()),
}))
.await
.expect("renders");
let said = format!("{:?}", packed[0].content);
assert!(said.contains("projects keys"), "{said}");
assert!(said.contains("/tmp/bag"), "{said}");
let work = server
.pick_up_work_prompt(Parameters(ReadyArgs {
project: Some("keys".into()),
}))
.await
.expect("renders");
assert!(format!("{:?}", work[0].content).contains("project keys"));
let cites = server
.check_citations_prompt(Parameters(IssueArgs {
issue: "keys-ab12".into(),
}))
.await
.expect("renders");
assert!(format!("{:?}", cites[0].content).contains("keys-ab12"));
}
#[tokio::test]
async fn a_slice_naming_nothing_is_refused() {
let (_dir, server) = server();
let err = server
.pack_a_slice_prompt(Parameters(SliceArgs {
projects: Some(" ".into()),
issues: None,
out: None,
}))
.await
.expect_err("an empty slice rendered");
assert!(format!("{err:?}").contains("names nothing"), "{err:?}");
}
#[tokio::test]
async fn the_defaults_are_in_the_text() {
let (_dir, server) = server();
let packed = server
.pack_a_slice_prompt(Parameters(SliceArgs {
projects: None,
issues: Some("keys-ab12".into()),
out: None,
}))
.await
.expect("renders");
let said = format!("{:?}", packed[0].content);
assert!(said.contains("./satchel"), "{said}");
assert!(said.contains("issues keys-ab12"), "{said}");
let work = server
.pick_up_work_prompt(Parameters(ReadyArgs { project: None }))
.await
.expect("renders");
assert!(format!("{:?}", work[0].content).contains("every project"));
}
}