diff --git a/crates/ledger-mcp/src/guards.rs b/crates/ledger-mcp/src/guards.rs new file mode 100644 index 0000000..d118c6a --- /dev/null +++ b/crates/ledger-mcp/src/guards.rs @@ -0,0 +1,174 @@ +//! The guards: the named ways the books refuse a change, each with the +//! tool that remedies it. +//! +//! ```text +//! GOAL: an agent that is refused is told which rule refused it and which +//! tool to call next, in the same words every time. +//! SOURCE: plan/served.md 4.6 (the result envelope: `guards` carry a code, +//! a message and a remedy tool); DECISION (P5). +//! INVARIANTS: +//! 1. Every guard names a remedy tool that exists (test +//! `every_guard_names_an_existing_tool`). +//! 2. A code is spelled one way: the enum's `code()`; a refusal whose +//! text is `{"code": .., "message": ..}` JSON or says a code in words +//! becomes the guard of that code, and any other refusal has none. +//! ``` + +use serde_json::{Value, json}; + +/// Every guard code the books know. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Guard { + /// The preview a confirmation carries is not the one the books would + /// show now. + StalePreview, + /// The books wait for the owner's format upgrade. + FormatUpgradePending, + /// The evidence policy wants a receipt before this posts. + ReceiptRequired, + /// An approval rule wants an approver's go-ahead. + ApprovalRequired, + /// The entry's party shares do not add up. + PartyShares, + /// The parties setting requires a party (or a no-party reason). + PartyRequired, + /// Drafts stand in the way of a close. + OpenDrafts, + /// A line on a control account carries no document tag. + UntaggedControlLine, + /// A vendor already has a bill with this reference. + DuplicateVendorRef, + /// The first invoice number cannot change once one is issued. + FirstNumberLocked, +} + +impl Guard { + /// Every guard, for the registry test. + pub(crate) const ALL: [Guard; 10] = [ + Guard::StalePreview, + Guard::FormatUpgradePending, + Guard::ReceiptRequired, + Guard::ApprovalRequired, + Guard::PartyShares, + Guard::PartyRequired, + Guard::OpenDrafts, + Guard::UntaggedControlLine, + Guard::DuplicateVendorRef, + Guard::FirstNumberLocked, + ]; + + /// The code an agent reads. + pub(crate) fn code(self) -> &'static str { + match self { + Guard::StalePreview => "stale_preview", + Guard::FormatUpgradePending => "format_upgrade_pending", + Guard::ReceiptRequired => "receipt_required", + Guard::ApprovalRequired => "approval_required", + Guard::PartyShares => "party_shares", + Guard::PartyRequired => "party_required", + Guard::OpenDrafts => "open_drafts", + Guard::UntaggedControlLine => "untagged_control_line", + Guard::DuplicateVendorRef => "duplicate_vendor_ref", + Guard::FirstNumberLocked => "first_number_locked", + } + } + + /// The guard of a code. + pub(crate) fn from_code(code: &str) -> Option { + Self::ALL.into_iter().find(|g| g.code() == code) + } + + /// The tool that remedies it; `tool` is the call that was refused (a + /// stale preview is remedied by previewing it again). + pub(crate) fn remedy_tool(self, tool: &str) -> String { + match self { + Guard::StalePreview => return tool.to_string(), + Guard::FormatUpgradePending => "books.start", + Guard::ReceiptRequired => "books.attach_receipt", + Guard::ApprovalRequired => "books.approve_entry", + Guard::PartyShares | Guard::PartyRequired => "books.set_entry_party", + Guard::OpenDrafts => "books.list_drafts", + Guard::UntaggedControlLine => "books.control_check", + Guard::DuplicateVendorRef => "books.search_entries", + Guard::FirstNumberLocked => "books.settings", + } + .to_string() + } +} + +/// The guards a refusal's text carries, as the envelope shows them: +/// `[{code, message, remedy_tool, remedy_args?}]`. `tool` is the refused +/// call. +pub(crate) fn from_refusal(tool: &str, text: &str) -> Vec { + // A structured refusal: the JSON the tool built. + if let Ok(Value::Object(o)) = serde_json::from_str::(text) + && let Some(code) = o.get("code").and_then(Value::as_str) + { + let remedy = o + .get("remedy_tool") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| Guard::from_code(code).map(|g| g.remedy_tool(tool))); + if let Some(remedy) = remedy { + let mut g = json!({ + "code": code, + "message": o.get("message").and_then(Value::as_str).unwrap_or(code), + "remedy_tool": remedy, + }); + if let Some(entry) = o.get("entry_id") { + g["remedy_args"] = json!({ "entry_id": entry }); + } + return vec![g]; + } + return Vec::new(); + } + // A refusal that names its code in words (the store's). + for g in Guard::ALL { + if text.starts_with(g.code()) { + return vec![json!({ + "code": g.code(), + "message": text, + "remedy_tool": g.remedy_tool(tool), + })]; + } + } + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::BooksServer; + + #[test] + fn every_guard_names_an_existing_tool() { + let names: Vec = BooksServer::tool_router() + .list_all() + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + for g in Guard::ALL { + let remedy = g.remedy_tool("books.post_entry"); + assert!( + names.contains(&remedy), + "guard {} names {remedy}, which is no tool", + g.code() + ); + assert_eq!(Guard::from_code(g.code()), Some(g)); + } + } + + #[test] + fn a_refusal_becomes_its_guard() { + let text = json!({"code":"approval_required","message":"needs approval","entry_id":7,"remedy_tool":"books.approve_entry"}).to_string(); + let g = from_refusal("books.post_entry", &text); + assert_eq!(g[0]["code"], "approval_required"); + assert_eq!(g[0]["remedy_args"]["entry_id"], 7); + let g = from_refusal( + "books.create_invoice", + "format_upgrade_pending: this needs documents_v2", + ); + assert_eq!(g[0]["remedy_tool"], "books.start"); + assert!(from_refusal("books.x", "no such account").is_empty()); + } +} diff --git a/crates/ledger-mcp/src/next_steps.rs b/crates/ledger-mcp/src/next_steps.rs new file mode 100644 index 0000000..e936267 --- /dev/null +++ b/crates/ledger-mcp/src/next_steps.rs @@ -0,0 +1,501 @@ +//! The result envelope: what every tool result carries besides its own +//! fields (plan 4.6). +//! +//! ```text +//! GOAL: an agent never has to guess what to do after a call: every result +//! says what follows (`next_steps`), what refused it and which tool +//! remedies that (`guards`), what to tell the human (`warnings`), +//! and, on a tool that can change the books, where the journal ends +//! now (`head`). +//! SOURCE: plan/served.md 4.6 and P5 step 1; DECISION (P5). +//! INVARIANTS: +//! 1. The envelope is applied in one place (`apply`, called by +//! `call_tool`), so no tool can forget it. +//! 2. Every result of a tool that is not read-only has a `head` (the +//! journal after the call's last write) and at least one next step. +//! 3. A tool's own fields are never overwritten: a payload that already +//! has `next_steps`, `guards`, `warnings` or `head` keeps it. +//! 4. A refused call has no head and no steps; a refusal that is a +//! guard (guards.rs) carries it as `structuredContent.guards`. +//! 5. Next steps from the books' attention list are the viewer's own +//! items (ledger_package::attention), filtered to the subject the +//! call touched. +//! ``` + +use super::BooksServer; +use super::guards; +use ledger_package::{Integrity, Severity, Target}; +use rmcp::model::{CallToolResponse, ContentBlock}; +use serde_json::{Map, Value, json}; +use std::collections::HashSet; +use std::sync::OnceLock; + +/// One thing to do next. +#[derive(Clone, Debug)] +pub(crate) struct Step { + /// The tool to call. + pub tool: Option, + /// Or the prompt to run. + pub prompt: Option, + /// Or a command for the human's computer (a download). + pub command: Option, + /// Its arguments. + pub args: Option, + /// Why. + pub why: String, + /// Whether the work is not done without it. + pub required: bool, +} + +impl Step { + pub(crate) fn tool(tool: &str, args: Option, why: &str, required: bool) -> Step { + Step { + tool: Some(tool.into()), + prompt: None, + command: None, + args, + why: why.into(), + required, + } + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + if let Some(t) = &self.tool { + o.insert("tool".into(), json!(t)); + } + if let Some(p) = &self.prompt { + o.insert("prompt".into(), json!(p)); + } + if let Some(c) = &self.command { + o.insert("command".into(), json!(c)); + } + if let Some(a) = &self.args { + o.insert("args".into(), a.clone()); + } + o.insert("why".into(), json!(self.why)); + o.insert("required".into(), json!(self.required)); + Value::Object(o) + } +} + +/// The tools that are not read-only: their results carry a `head`. +pub(crate) fn mutating_tools() -> &'static HashSet { + static SET: OnceLock> = OnceLock::new(); + SET.get_or_init(|| { + BooksServer::tool_router() + .list_all() + .into_iter() + .filter(|t| { + !t.annotations + .as_ref() + .and_then(|a| a.read_only_hint) + .unwrap_or(false) + }) + .map(|t| t.name.to_string()) + .collect() + }) +} + +/// The steps a tool's own result implies (no books needed): the confirm +/// after a preview, and what follows a particular act. +pub(crate) fn tool_steps(tool: &str, args: Option<&Map>, result: &Value) -> Vec { + let mut steps = Vec::new(); + let confirmed = args + .and_then(|a| a.get("confirmed")) + .and_then(Value::as_bool) + .unwrap_or(false); + let entry = result + .get("entry_id") + .or_else(|| args.and_then(|a| a.get("entry_id"))) + .and_then(Value::as_u64); + let with_entry = |id: u64| Some(json!({ "entry_id": id })); + // A preview: what is missing first, then the human confirms, then the + // same call with the digest. + if let (false, Some(preview), Some(args)) = ( + confirmed, + result.get("preview").and_then(Value::as_str), + args, + ) { + if tool == "books.post_entry" + && result.get("receipt_missing").and_then(Value::as_bool) == Some(true) + && let Some(id) = entry + { + steps.push(Step::tool( + "books.attach_receipt", + with_entry(id), + "This entry needs a receipt before it posts: attach the file (upload it first).", + true, + )); + steps.push(Step::tool( + "books.set_draft_state", + Some(json!({ "entry_id": id, "state": "awaiting_evidence" })), + "Or leave the draft waiting, with a note saying what for, until the receipt arrives.", + false, + )); + } + let mut confirm = args.clone(); + confirm.insert("confirmed".into(), json!(true)); + confirm.insert("preview".into(), json!(preview)); + steps.push(Step::tool( + tool, + Some(Value::Object(confirm)), + "Show this preview to the human; only when they agree, make the same call with confirmed true and this preview.", + true, + )); + return steps; + } + match tool { + "books.set_setting" | "books.add_account" => { + steps.push(Step::tool( + "books.add_account", + None, + "Add the accounts the human's chart needs, one at a time.", + false, + )); + steps.push(Step::tool( + "books.draft_entry", + None, + "Or record a transaction: draft it, add its lines, preview it, then post.", + false, + )); + } + "books.draft_entry" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.add_line", + with_entry(id), + "Add the draft's lines: each is an account, a side and an amount; debits must equal credits.", + true, + )); + } + } + "books.add_line" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.add_line", + with_entry(id), + "Add the next line of the draft.", + false, + )); + steps.push(Step::tool( + "books.post_entry", + with_entry(id), + "When the lines balance, preview the post for the human.", + false, + )); + } + } + "books.set_draft_state" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.attach_receipt", + with_entry(id), + "When the document arrives, upload and attach it to this draft.", + false, + )); + } + } + "books.attach_receipt" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.post_entry", + with_entry(id), + "The evidence is attached: preview the post again for the human.", + false, + )); + } + } + "books.post_entry" | "books.reverse_entry" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.show", + with_entry(id), + "Show the human the entry and what it moved.", + false, + )); + if tool == "books.post_entry" { + steps.push(Step::tool( + "books.reverse_entry", + with_entry(id), + "Only if this was posted in error: reverse it (posted entries are never edited).", + false, + )); + } + } + } + "books.draft_from_template" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.post_entry", + with_entry(id), + "Preview the draft for the human, who then confirms it.", + false, + )); + } + } + "books.record_statement" => { + steps.push(Step::tool( + "books.mark_cleared", + None, + "Mark each transaction the statement shows as cleared.", + true, + )); + let (account, end, close, attachment) = ( + result.get("account_code"), + result.get("period_end"), + result.get("closing_balance"), + result.get("attachment_id"), + ); + if let (Some(account), Some(end), Some(close), Some(attachment)) = + (account, end, close, attachment) + { + steps.push(Step::tool( + "books.reconcile", + Some(json!({ + "account": account, + "statement_date": end, + "statement_balance": close, + "statement_attachment": attachment, + })), + "When every line is marked, preview the reconciliation against the statement.", + false, + )); + } + } + "books.mark_cleared" => steps.push(Step::tool( + "books.reconcile", + None, + "Mark the rest, then preview the reconciliation against the statement.", + false, + )), + "books.reconcile" => steps.push(Step::tool( + "books.reconciliations", + None, + "See the reconciliation among the account's others.", + false, + )), + "books.list_periods" => { + for p in result + .get("periods") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|p| p.get("status").and_then(Value::as_str) == Some("open")) + .take(3) + { + if let Some(id) = p.get("id").and_then(Value::as_u64) { + steps.push(Step::tool( + "books.close_period", + Some(json!({ "period_id": id })), + "Once everything in this period is recorded and reconciled, preview its close.", + false, + )); + } + } + } + "books.close_period" => steps.push(Step::tool( + "books.verify", + None, + "Verify the books after a close: the chain, the replay and every stored file.", + false, + )), + "books.export_cpa_package" => { + if let Some(cmd) = result.pointer("/file/download").and_then(Value::as_str) { + steps.push(Step { + tool: None, + prompt: None, + command: Some(cmd.to_string()), + args: None, + why: "Run this to save the package in a folder you name, within 15 minutes." + .into(), + required: true, + }); + } + } + _ => {} + } + steps +} + +/// The attention items about the subject of the call, as steps. +fn attention_steps(server: &BooksServer, subject: Subject, store: &ledger_store::Store) -> Vec { + let cfg = server.settings(); + let settings = ledger_package::Settings { + company: &cfg.company, + cash_accounts: &cfg.roles.cash, + financing_accounts: &cfg.roles.financing, + investing_accounts: &cfg.roles.investing, + }; + let today = ledger_core::settings::today_in(cfg.timezone.as_deref()); + let items = ledger_package::find_with(store, today, &settings, &Integrity::default()); + items + .into_iter() + .filter(|a| match (subject, a.target) { + (Subject::Entry(s), Target::Entry(t)) => s == t, + (Subject::Invoice(s), Target::Invoice(t)) => s == t, + _ => false, + }) + .filter_map(|a| { + let (tool, args) = a.tool?; + let args = if args.is_null() { + match (a.target, tool.as_str()) { + ( + Target::Entry(id), + "books.attach_receipt" | "books.set_entry_party" | "books.set_entry_settles", + ) => Some(json!({ "entry_id": id })), + _ => None, + } + } else { + Some(args) + }; + let title: String = a.title.chars().take(160).collect(); + Some(Step::tool(&tool, args, &title, a.severity == Severity::Problem)) + }) + .take(4) + .collect() +} + +/// What a call touched, for the attention filter. +#[derive(Clone, Copy)] +enum Subject { + Entry(u64), + Invoice(u64), +} + +fn subject_of(args: Option<&Map>, result: &Value) -> Option { + let pick = |k: &str| { + result + .get(k) + .or_else(|| args.and_then(|a| a.get(k))) + .and_then(Value::as_u64) + }; + pick("entry_id") + .map(Subject::Entry) + .or_else(|| pick("invoice_id").map(Subject::Invoice)) +} + +/// The fallback of a tool that changes things and has nothing particular +/// to follow it. +fn digest_step() -> Step { + Step::tool( + "books.digest", + None, + "See what changed and offer it to the human.", + false, + ) +} + +/// Put the envelope on a tool's answer (INVARIANT 1). `wrote` is the head +/// after the call's last write, when it wrote. +pub(crate) fn apply( + server: &BooksServer, + tool: &str, + args: Option<&Map>, + out: Result, +) -> Result { + let Ok(CallToolResponse::Complete(mut r)) = out else { + return out; + }; + if r.is_error == Some(true) { + // INVARIANT 4. + let text = r + .content + .iter() + .find_map(|c| match c { + ContentBlock::Text(t) => Some(t.text.as_str()), + _ => None, + }) + .unwrap_or(""); + let guards = guards::from_refusal(tool, text); + if !guards.is_empty() && r.structured_content.is_none() { + r.structured_content = Some(json!({ "guards": guards })); + } + return Ok(CallToolResponse::Complete(r)); + } + let Some(Value::Object(mut body)) = r.structured_content.take() else { + return Ok(CallToolResponse::Complete(r)); + }; + let before = Value::Object(body.clone()); + let mutating = mutating_tools().contains(tool); + let result = Value::Object(body.clone()); + let mut steps = tool_steps(tool, args, &result); + let head = if mutating { + server.lock().ok().map(|store| { + let (seq, hash) = store.head(); + if let Some(subject) = subject_of(args, &result) + && steps.iter().all(|s| !s.required) + { + steps.extend(attention_steps(server, subject, &store)); + } + json!({ "seq": seq, "hash": hash }) + }) + } else { + None + }; + if mutating && steps.is_empty() { + steps.push(digest_step()); + } + if !body.contains_key("next_steps") { + body.insert( + "next_steps".into(), + Value::Array(steps.iter().map(Step::to_json).collect()), + ); + } + body.entry("guards").or_insert_with(|| json!([])); + body.entry("warnings").or_insert_with(|| json!([])); + if let Some(head) = head { + body.entry("head").or_insert(head); + } + let after = Value::Object(body); + // The same JSON in the text block (server.rs INVARIANT 1). + for c in &mut r.content { + if let ContentBlock::Text(t) = c + && serde_json::from_str::(&t.text).is_ok_and(|v| v == before) + { + t.text = after.to_string(); + } + } + r.structured_content = Some(after); + Ok(CallToolResponse::Complete(r)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_preview_gets_its_confirm_step() { + let args = json!({"entry_id": 3}).as_object().cloned().unwrap(); + let steps = tool_steps( + "books.post_entry", + Some(&args), + &json!({"entry_id": 3, "preview": "abc", "status": "preview"}), + ); + assert_eq!(steps.len(), 1); + let a = steps[0].args.as_ref().unwrap(); + assert_eq!(a["confirmed"], true); + assert_eq!(a["preview"], "abc"); + assert!(steps[0].required); + } + + #[test] + fn every_step_a_tool_names_exists() { + let names: HashSet = BooksServer::tool_router() + .list_all() + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + let result = json!({"entry_id": 1, "file": {"download": "x"}}); + for tool in mutating_tools() { + for s in tool_steps(tool, Some(&Map::new()), &result) + .into_iter() + .chain([digest_step()]) + { + if let Some(t) = s.tool { + assert!(names.contains(&t), "{tool}: step names {t}, which is no tool"); + } + } + } + } +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 260283a..0a70caf 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -1391,6 +1391,10 @@ enum ShownDates { // Tools // --------------------------------------------------------------------------- +#[path = "guards.rs"] +mod guards; +#[path = "next_steps.rs"] +mod next_steps; #[path = "tools/access.rs"] mod access_tools; #[path = "tools/backup.rs"] @@ -1704,6 +1708,8 @@ impl ServerHandler for BooksServer { let running_call = call.clone(); // Kept for the record of a call the server stopped before running. let request_args = request.arguments.clone(); + let tool_name = request.name.to_string(); + let envelope_args = request.arguments.clone(); let result = run_blocking(&self.store, move || { Box::pin(async move { WROTE.with(|w| w.borrow_mut().take()); @@ -1740,6 +1746,8 @@ impl ServerHandler for BooksServer { std::panic::resume_unwind(panic) } }; + // The result envelope (next_steps.rs INVARIANT 1). + let out = next_steps::apply(&server, &tool_name, envelope_args.as_ref(), out); let mut c = running_call; c.digested(digest); let recorded = c.record_blocking(outcome_of(&out), wrote); diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs index e21c92a..efce2c8 100644 --- a/crates/ledger-mcp/src/server_tests.rs +++ b/crates/ledger-mcp/src/server_tests.rs @@ -1528,3 +1528,95 @@ fn a_tool_list_is_cached_only_for_its_caller() { } }); } + +/// Plan 4.6 (P5 step 1): the envelope is put on every result in one +/// place; a tool that can change the books answers with the journal's +/// `head` and at least one next step, whatever it said itself, and the +/// text block says the same as the structured content. +#[test] +fn every_mutating_result_has_head_and_next_steps() { + use rmcp::model::{CallToolResponse, CallToolResult, ContentBlock}; + let dir = unit_dir("envelope"); + let server = served(store(&dir)); + let (seq, hash) = server.lock().unwrap().head(); + let mutating = next_steps::mutating_tools(); + assert!(mutating.contains("books.post_entry") && !mutating.contains("books.trial_balance")); + let mut read_only = 0; + for tool in BooksServer::tool_router().list_all() { + let name = tool.name.to_string(); + let answer = CallToolResponse::Complete(CallToolResult::structured( + serde_json::json!({"status": "ok"}), + )); + let CallToolResponse::Complete(r) = next_steps::apply(&server, &name, None, Ok(answer)) + .expect("an answer stays an answer") + else { + panic!("{name}: not a complete answer"); + }; + let body = r.structured_content.expect("structured"); + assert!(body["guards"].is_array() && body["warnings"].is_array(), "{name}"); + let ContentBlock::Text(t) = &r.content[0] else { + panic!("{name}: no text block"); + }; + assert_eq!( + serde_json::from_str::(&t.text).unwrap(), + body, + "{name}: the text says what the structure says" + ); + if mutating.contains(&name) { + assert_eq!(body["head"]["seq"], seq, "{name}"); + assert_eq!(body["head"]["hash"], hash.as_str(), "{name}"); + let steps = body["next_steps"].as_array().expect("next_steps"); + assert!(!steps.is_empty(), "{name}: no next step"); + for s in steps { + assert!(s["why"].is_string() && s["required"].is_boolean(), "{name}"); + } + } else { + read_only += 1; + assert!(body.get("head").is_none(), "{name}: a read has no head"); + } + } + assert!(read_only > 20); +} + +/// A tool's own `next_steps`, `warnings` and `head` are kept (the +/// envelope fills only what the result lacks), and a refusal that is a +/// guard carries it. +#[test] +fn the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard() { + use rmcp::model::{CallToolResponse, CallToolResult}; + let dir = unit_dir("envelope-keeps"); + let server = served(store(&dir)); + let own = serde_json::json!({ + "next_steps": [{"tool": "books.digest", "why": "mine", "required": false}], + "warnings": ["careful"], + "head": {"seq": 0, "hash": "x"}, + }); + let CallToolResponse::Complete(r) = next_steps::apply( + &server, + "books.post_entry", + None, + Ok(CallToolResponse::Complete(CallToolResult::structured(own.clone()))), + ) + .unwrap() else { + panic!() + }; + let body = r.structured_content.unwrap(); + assert_eq!(body["next_steps"], own["next_steps"]); + assert_eq!(body["warnings"], own["warnings"]); + assert_eq!(body["head"], own["head"]); + let refused = CallToolResponse::Complete(CallToolResult::error(vec![ + rmcp::model::ContentBlock::text( + serde_json::json!({"code": "approval_required", "message": "needs approval", "remedy_tool": "books.approve_entry"}) + .to_string(), + ), + ])); + let CallToolResponse::Complete(r) = + next_steps::apply(&server, "books.post_entry", None, Ok(refused)).unwrap() + else { + panic!() + }; + assert_eq!(r.is_error, Some(true)); + let g = &r.structured_content.unwrap()["guards"][0]; + assert_eq!(g["code"], "approval_required"); + assert_eq!(g["remedy_tool"], "books.approve_entry"); +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index b8aa6db..52b9540 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -446,6 +446,34 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. `books.purge_tin`, `books.consolidate_duplicates` and `books.discard_draft`. (`books.void_document`, `books.run_depreciation` and `books.run_recurring` post at once and have no `confirmed`.) +- **The result envelope (P5, plan 4.6).** Every tool result, whatever the + tool, is given the same four fields in one place (`call_tool`, + `next_steps.rs::apply`), after the tool has answered; a tool's own field of + the same name is kept, never overwritten: + `next_steps: [{ tool | prompt | command, args?, why, required }]` (what to + do next: after a preview, the same call with `confirmed: true` and the + returned `preview`; after a post, `books.show` of the entry, plus the + Needs-attention items about that entry or invoice, the viewer's own, + `required` when they are problems; after a close, `books.verify`; after + `books.export_cpa_package`, a `command` step carrying the download + command; every other tool that can change the books ends with + `books.digest`), `guards: [{ code, message, remedy_tool, remedy_args? }]` + (empty on success; on a refusal that is a guard, the refusal's + `structuredContent` is `{ guards: [..] }` and its text is unchanged), + `warnings: [..]` (a tool's own `warnings` is that field), and, on every + tool that is not read-only, `head: { seq, hash }`, the journal's head + after the call's last write (or as it stands, when the call wrote + nothing). The guard codes are the enum in `guards.rs`, each with its + remedy tool: `stale_preview` (the tool again, previewed), + `format_upgrade_pending` (`books.start`), `receipt_required` + (`books.attach_receipt`), `approval_required` (`books.approve_entry`), + `party_shares` and `party_required` (`books.set_entry_party`), + `open_drafts` (`books.list_drafts`), `untagged_control_line` + (`books.control_check`), `duplicate_vendor_ref` (`books.search_entries`) + and `first_number_locked` (`books.settings`). Tests: + `every_mutating_result_has_head_and_next_steps`, + `the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard`, + `every_guard_names_an_existing_tool`. - **Errors are typed strings** mirroring `LedgerError` variants, with the offending values included (e.g. unbalanced totals).