Files
wanderbooks/crates/ledger-mcp/src/server_tests.rs
T
swaitsandClaude Fable 5.1 be58059830 merge: P4b S6, paying on terms (branch S6, 6c4480cd)
The third of the paused branches: Ledger::settle with discounts and the
processor's fee (kernel::settle, Kani settle_is_exact, Lean
settle_posts_a_document_step), books.receive_payment and books.pay_bill
with applications, books.bills_due, the agings' detail and tie-out, the
aging exports and the CPA package's aging CSVs, the handlers in
tools/settlements.rs. Twelve files conflicted with the S4.4 and S5
merges, resolved as plan 8.0.2 says: both routers; both test appends
(the shared match arm in arap_standing.rs spliced into both tests; the
shared `.unwrap()` in store.rs); the scope table at 124; one
invoices-and-bills handbook (S5's send step 1 with S6's steps 2, 3, 5
and procedures) registered once; docs/mcp-interface.md with S4.4's
create docs and S6's payment docs; S5's law renumbered 35 and S6's 36
(S1's is 34); the parity rows and sections of both. S6's wire tests
create their bills as S4.4's contract now requires (confirmed, the
vendor's number, a line account, the vendor's invoice uploaded) and live
in their own module beside S4.4's tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 21:26:40 -06:00

1513 lines
54 KiB
Rust

//! The server's tests: the whole tool surface (`annotations_complete`,
//! the text and schema checks, the document/description budgets), the
//! access door, the request machinery and the resources. They sit beside
//! `server.rs` (a `#[path]` module of it) so `server.rs` stays under its
//! 3,000-line bar (plan/served.md 8.2 U0a); a tool group's own tests live
//! in its group file, and the fixtures both use are in
//! `server::test_support`.
use super::*;
use crate::server::test_support::*;
/// An upload's limit, 25 MiB exactly (plan 4.7); nothing comes inline
/// (D-46).
#[test]
fn the_file_limits_are_the_plans() {
assert_eq!(ledger_store::uploads::MAX_UPLOAD, 26_214_400);
// Plan 4.7: an upload lasts 15 minutes; 16 under way per token.
assert_eq!(ledger_store::uploads::UPLOAD_TTL, 900);
assert_eq!(ledger_store::uploads::MAX_LIVE, 16);
assert_eq!(ledger_store::uploads::MAX_NAME_BYTES, 255);
assert_eq!(crate::local::MAX_BODY, 1024 * 1024);
}
/// Every tool says what it is and what it does to the books (plan
/// 4.9, "Annotations (P0)"): a title and readOnlyHint on all; a
/// mutating tool also says whether it is destructive. None asks the
/// client for an interaction of its own (owner decision 2026-10-16:
/// no added prompts).
#[test]
fn annotations_complete() {
// Every tool's hints, pinned (MCP's ToolAnnotations): R read-only;
// W changes the books, additively; D destructive (posts, or
// overwrites, unlinks, voids, purges what was there: a vault
// record, a link, a reconciliation); I idempotent (a repeat with
// the same arguments leaves the journal and the vault as they
// were: spec_vault's idempotent_tools_change_nothing_when_repeated
// calls each twice). export_cpa_package writes a file, so it is
// not read-only.
type Hints = (Option<bool>, Option<bool>, Option<bool>);
const R: Hints = (Some(true), None, None);
const W: Hints = (Some(false), Some(false), None);
const D: Hints = (Some(false), Some(true), None);
const WI: Hints = (Some(false), Some(false), Some(true));
const DI: Hints = (Some(false), Some(true), Some(true));
let table: [(&str, Hints); 124] = [
("books.handbook", R),
("books.start", R),
("books.set_setting", DI),
("books.export_settings", R),
("books.conventions", R),
("books.set_convention", W),
("books.retire_convention", D),
("books.record_decision", W),
("books.decisions", R),
("books.raise_item", W),
("books.resolve_item", W),
("books.open_items", R),
("books.draft_from_template", W),
("books.approve_entry", W),
("books.chart", R),
("books.add_account", W),
("books.trial_balance", R),
("books.profit_and_loss", R),
("books.balance_sheet", R),
("books.cash_flow", R),
("books.draft_entry", W),
("books.add_line", W),
("books.post_entry", D),
("books.reverse_entry", D),
("books.attach_receipt", W),
("books.file_document", W),
("books.link_receipt", W),
("books.unlink_receipt", D),
("books.withdraw_attachment", D),
("books.link_attachment", W),
("books.unlink_attachment", D),
("books.attachments", R),
("books.list_receipts", R),
("books.get_receipt", R),
("books.get_entry", R),
("books.search_entries", R),
("books.show", R),
("books.verify", WI),
("books.digest", R),
("books.import_bank_csv", W),
("books.create_schedule", W),
("books.list_schedules", R),
("books.run_schedules", DI),
("books.create_recurring", W),
("books.list_recurring", R),
("books.run_recurring", DI),
("books.record_payout", W),
("books.list_vendors", R),
("books.set_vendor_1099", WI),
("books.set_entry_vendor", W),
("books.vendor_1099", W),
("books.add_instrument", W),
("books.instruments", R),
("books.set_entry_instrument", WI),
("books.chart_template", R),
("books.tax_categories", R),
("books.renumber_account", D),
("books.rename_account", D),
("books.label_asset", W),
("books.correct_asset", D),
("books.set_asset_tax_basis", W),
("books.asset_labels", R),
("books.settings", R),
("books.roles", R),
("books.assign_role", D),
("books.set_tax_category", W),
("books.import_chart_csv", W),
("books.begin_upload", W),
("books.export", R),
("books.register_asset", W),
("books.asset_schedule", R),
("books.list_assets", R),
("books.run_depreciation", DI),
("books.dispose_asset", D),
("books.create_period", W),
("books.close_period", D),
("books.list_periods", R),
("books.mark_cleared", W),
("books.reconcile", D),
("books.record_statement", W),
("books.statements", R),
("books.reconciliations", R),
("books.void_statement", D),
("books.void_reconciliation", D),
("books.add_customer", W),
("books.add_vendor", W),
("books.add_party", W),
("books.update_party", W),
("books.set_party_roles", WI),
("books.set_w9", D),
("books.set_tin", D),
("books.get_w9", W),
("books.purge_tin", D),
("books.set_w4", W),
("books.parties", R),
("books.party", R),
("books.set_entry_party", W),
("books.create_invoice", D),
("books.create_bill", D),
("books.receive_payment", D),
("books.pay_bill", D),
("books.void_document", D),
("books.mark_sent", W),
("books.control_check", R),
("books.documents", R),
("books.document", R),
("books.ar_aging", R),
("books.ap_aging", R),
("books.bills_due", R),
("books.set_fx_rate", W),
("books.run_payroll", D),
("books.remit_payroll_taxes", D),
("books.list_payroll_runs", R),
("books.add_class", W),
("books.list_classes", R),
("books.add_project", W),
("books.update_project", WI),
("books.projects", R),
("books.project", R),
("books.profit_and_loss_by_class", R),
("books.export_cpa_package", W),
("books.access_log", R),
("books.backup", R),
("books.back_up_now", WI),
];
let tools = BooksServer::tool_router().list_all();
assert_eq!(tools.len(), table.len());
let mut names: Vec<&str> = table.iter().map(|(n, _)| *n).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), table.len(), "a tool is in the table twice");
for t in &tools {
let name = &t.name;
assert!(
t.title.as_deref().is_some_and(|s| !s.trim().is_empty()),
"{name} has no title"
);
let a = t
.annotations
.as_ref()
.unwrap_or_else(|| panic!("{name} has no annotations"));
let want = table
.iter()
.find(|(n, _)| n == name)
.unwrap_or_else(|| panic!("{name} is not in the table"))
.1;
assert_eq!(
(a.read_only_hint, a.destructive_hint, a.idempotent_hint),
want,
"{name}: (readOnlyHint, destructiveHint, idempotentHint)"
);
let json = serde_json::to_string(t).unwrap().to_lowercase();
for word in ["requiresuserinteraction", "requires_user_interaction"] {
assert!(!json.contains(word), "{name} asks for user interaction");
}
// The books are a closed world: no tool reaches beyond them,
// but the one that pushes a backup to the owner's targets.
assert_eq!(
a.open_world_hint,
Some(*name == "books.back_up_now"),
"{name}: openWorldHint"
);
}
// docs/mcp-interface.md names the 29 destructive tools.
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29);
}
/// Every refusal an agent can be given whose text is fixed here, or
/// in the crates under the server: the error templates of every
/// variant (scanned from their sources, so a new variant is linted
/// too), representative values of those whose text is built, and the
/// server's own refusal constants.
fn agent_refusals() -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for (krate, src) in [
(
"ledger-core",
include_str!("../../ledger-core/src/error.rs"),
),
(
"ledger-store",
include_str!("../../ledger-store/src/error.rs"),
),
(
"ledger-vault",
include_str!("../../ledger-vault/src/lib.rs"),
),
(
"ledger-import bank",
include_str!("../../ledger-import/src/bank_csv.rs"),
),
(
"ledger-import chart",
include_str!("../../ledger-import/src/chart_csv.rs"),
),
(
"ledger-import iif",
include_str!("../../ledger-import/src/iif.rs"),
),
(
"ledger-import pdf",
include_str!("../../ledger-import/src/pdf.rs"),
),
(
"ledger-import xlsx",
include_str!("../../ledger-import/src/xlsx.rs"),
),
(
"ledger-store uploads",
include_str!("../../ledger-store/src/uploads.rs"),
),
] {
let mut rest = src;
let mut n = 0;
while let Some(at) = rest.find("#[error(") {
rest = &rest[at + 8..];
let end = rest.find(")]").expect("an #[error] attribute ends");
out.push((format!("{krate} error template"), rest[..end].to_string()));
rest = &rest[end..];
n += 1;
}
assert!(n > 0, "{krate}: no error templates found");
}
// The files and backup tools' refusals (and descriptions), and the
// backups' own words: their sentences. (What git itself says of a
// failed backup reaches the human as git said it: not ours.)
for (name, src) in [
("tools/files.rs", include_str!("tools/files.rs")),
("tools/backup.rs", include_str!("tools/backup.rs")),
("backup.rs", include_str!("backup.rs")),
] {
let src = src.split("#[cfg(test)]").next().unwrap_or(src);
for line in src.lines() {
let line = line.trim_start();
if line.starts_with("//") {
continue;
}
for (i, text) in line.split('"').enumerate() {
if i % 2 == 1 && text.len() > 30 && text.contains(' ') {
out.push((format!("{name} text"), text.to_string()));
}
}
}
}
let newer = |verified| ledger_store::StoreError::NewerJournal {
seq: 7,
event_type: "settings.set".into(),
need: "journal format 2".into(),
verified,
};
out.push(("NewerJournal".into(), newer(true).to_string()));
out.push(("NewerJournal unverified".into(), newer(false).to_string()));
out.push((
"FormatUpgradePending".into(),
ledger_store::StoreError::FormatUpgradePending {
need: "journal format 2".into(),
}
.to_string(),
));
out.push((
"RuleNotKnown".into(),
ledger_core::form1099::default_threshold(9999)
.unwrap_err()
.to_string(),
));
for (what, text) in [
("STOPPED", STOPPED),
("tin_files::TIN_FILE", tin_files::TIN_FILE),
("tin_files::TIN_HELD", tin_files::TIN_HELD),
("tin_files::TIN_PROBE", tin_files::TIN_PROBE),
("tin_files::TIN_PRINTED", tin_files::TIN_PRINTED),
] {
out.push((what.to_string(), text.to_string()));
}
// Refusals and notes built inline, where the books' own record is
// kept: every string literal of these sources outside their tests.
for (what, src) in [
("tools/kept.rs", include_str!("tools/kept.rs")),
(
"ledger-config codec",
include_str!("../../ledger-config/src/codec.rs"),
),
(
"ledger-core settings",
include_str!("../../ledger-core/src/settings.rs"),
),
(
"ledger-core conventions",
include_str!("../../ledger-core/src/conventions.rs"),
),
(
"ledger-core ledger",
include_str!("../../ledger-core/src/ledger.rs"),
),
("settings.rs", include_str!("settings.rs")),
] {
let code = src.split("#[cfg(test)]").next().unwrap_or(src);
let literals = string_literals(code);
assert!(!literals.is_empty(), "{what}: no literals found");
out.extend(literals.into_iter().map(|l| (format!("{what} literal"), l)));
}
out
}
/// The text of every plain string literal ("...") in Rust source,
/// skipping comments (a rough scanner: enough for the lint).
fn string_literals(src: &str) -> Vec<String> {
let mut out = Vec::new();
for line in src.lines() {
let line = line.trim_start();
if line.starts_with("//") {
continue;
}
let mut chars = line.chars().peekable();
let mut cur: Option<String> = None;
while let Some(c) = chars.next() {
match (&mut cur, c) {
(None, '"') => cur = Some(String::new()),
(None, '/') if chars.peek() == Some(&'/') => break,
(Some(s), '\\') => {
if let Some(n) = chars.next() {
s.push('\\');
s.push(n);
}
}
(Some(s), '"') => {
out.push(std::mem::take(s));
cur = None;
}
(Some(s), c) => s.push(c),
(None, _) => {}
}
}
}
out
}
/// No text an agent is given sends it to the books' files or to tools
/// outside these (plan I-12): no helper commands, home paths, data
/// dir names or flags, file names of the books, git, GitHub, pull
/// requests, repo docs, the old company's examples, or suggestions to
/// read files. Linted whole: every tool's JSON (title, description,
/// input and output schemas), every resource, the connect text, every
/// handbook section, and every refusal fixed in code
/// ([`agent_refusals`]). (No resource templates are advertised yet.)
/// What `tool_text_lint` finds in one text beyond its fixed list: a
/// helper command however it ends (`just` as a word before a
/// backtick, a line break, a tab or the end), and where the books are
/// ("beside the books", "books dir").
fn helper_or_location(what: &str, text: &str) -> Vec<String> {
let lower = text.to_lowercase();
let mut found = Vec::new();
for (at, _) in lower.match_indices("just") {
let prev = lower[..at].chars().next_back();
let next = lower[at + 4..].chars().next();
let word_start = !prev.is_some_and(|c| c.is_alphanumeric() || c == '_');
if word_start && matches!(next, None | Some('`' | '\n' | '\r' | '\t')) {
found.push(format!("{what} names a helper command (`just`)"));
}
}
if lower.contains("beside the books") {
found.push(format!(
"{what} says where the books are: \"beside the books\""
));
}
if lower.contains("books dir") {
found.push(format!("{what} says where the books are: \"books dir\""));
}
found
}
/// The lint's own checks catch what they name, and pass ordinary words.
#[test]
fn tool_text_lint_catches_helpers_and_locations() {
for (text, n) in [
("run `just`", 1),
("run just\nthen", 1),
("run just\tgui", 1),
("run just", 1),
("just`gui`", 1),
("Just\n", 1),
("adjust`", 0),
("just after posting", 0),
("just, then", 0),
("justly", 0),
("the file beside the books", 1),
("in the books dir", 1),
("a path outside the books dir", 1),
("the books directory", 1),
("bookshelf", 0),
] {
assert_eq!(helper_or_location("text", text).len(), n, "{text:?}");
}
assert_eq!(
helper_or_location("tool books.get_w9", "outside the books dir").len(),
1
);
assert_eq!(
helper_or_location("tool books.chart", "outside the books dir").len(),
1
);
}
/// No tool takes a path on the server's computer (plan 4.7, D-28,
/// D-29, D-46): files come in by upload, and go out as exports.
#[test]
fn no_tool_takes_a_path() {
fn names(v: &serde_json::Value, out: &mut Vec<String>) {
match v {
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::Object(props)) = map.get("properties") {
out.extend(props.keys().cloned());
}
map.values().for_each(|v| names(v, out));
}
serde_json::Value::Array(items) => items.iter().for_each(|v| names(v, out)),
_ => {}
}
}
let mut found = Vec::new();
for t in BooksServer::tool_router().list_all() {
let mut props = Vec::new();
names(
&serde_json::Value::Object((*t.input_schema).clone()),
&mut props,
);
for p in props {
let lower = p.to_ascii_lowercase();
// Names that hold a place to read or write: whole words,
// so `link_to`, `*_file_name`, `tin_on_file` and `date_to`
// pass.
let words: Vec<&str> = lower.split('_').collect();
let pathlike = lower.starts_with("save")
|| lower.contains("path")
|| [
"dir",
"folder",
"root",
"dest",
"destination",
"output",
"location",
"local",
]
.iter()
.any(|w| words.contains(w))
|| (words.last() == Some(&"file") && !lower.ends_with("on_file"))
|| lower == "to"
|| lower == "from"
|| lower == "source";
if pathlike {
found.push(format!("{} {p}", t.name));
}
}
}
found.sort();
found.dedup();
assert!(found.is_empty(), "path-like parameters: {found:?}");
}
#[test]
fn tool_text_lint() {
const NEVER: &[&str] = &[
"just ",
"~/",
"/home",
".config/wanderbooks",
"books-data/",
"data dir",
"--data-dir",
"--import-root",
"vault.key",
"journal.jsonl",
".jsonl",
"sqlite",
".gitignore",
"github",
"pull request",
"docs/",
".md",
"read the journal",
"the journal is the evidence",
"read the file",
"open the file",
"cat ",
// Gone in P2 slice 5: files come by upload.
"import_local_file",
"w9_file_path",
"import folder",
"import root",
"put_url",
"human_url",
"fl-0001",
// Gone with D-46: a file never passes through the agent.
"content_base64",
"csv_text",
"base64",
"256 kib",
];
// Whole words only: "digit" and "committee" are fine.
const NEVER_WORDS: &[&str] = &["git", "commit"];
let mut texts: Vec<(String, String)> = Vec::new();
for t in BooksServer::tool_router().list_all() {
texts.push((
format!("tool {}", t.name),
serde_json::to_string(&t).unwrap(),
));
}
for r in resource_list() {
texts.push((
format!("resource {}", r.uri),
serde_json::to_string(&r).unwrap(),
));
}
texts.extend(agent_refusals());
for threshold in ["0.00", "25.00"] {
let settings = Settings {
policy: ReceiptPolicy {
require_receipts_above: threshold.into(),
..ReceiptPolicy::default()
},
..Settings::default()
};
texts.push((
"connect text".into(),
handbook::connect_text(&settings, "USD"),
));
for (id, _) in handbook::SECTIONS {
for declared in [true, false] {
texts.push((
format!("handbook {id}"),
handbook::section(id, &settings, "USD", declared).unwrap(),
));
}
}
}
// Every tool the text names is one there is.
let tools: std::collections::HashSet<String> = BooksServer::tool_router()
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
let mut unknown: Vec<String> = Vec::new();
// Settings keys (`books.cash_accounts`) are named like tools: a
// name that is one is a setting, not a tool.
let keys: std::collections::HashSet<&str> =
ledger_core::settings::KEYS.iter().map(|k| k.name).collect();
for (what, text) in texts
.iter()
.filter(|(w, _)| !w.contains("settings literal"))
{
for (i, _) in text.match_indices("books.") {
let name: String = text[i..]
.chars()
.take_while(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_' || *c == '.'
})
.collect();
let name = name.trim_end_matches('.');
// "the books." ends a sentence.
if name == "books" {
continue;
}
let before = text[..i].chars().next_back();
if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '/') {
continue;
}
if !tools.contains(name) && !keys.contains(name) {
unknown.push(format!("{what}: {name}"));
}
}
}
unknown.sort();
unknown.dedup();
assert!(
unknown.is_empty(),
"text names tools there aren't: {unknown:?}"
);
let words = |text: &str| {
text.split(|c: char| !c.is_alphanumeric() && c != '_')
.map(str::to_string)
.collect::<Vec<_>>()
};
let mut found: Vec<String> = Vec::new();
for (what, text) in &texts {
// A handbook resource's own address is not a file name.
let lower = text.to_lowercase().replace("books://handbook", "");
for bad in NEVER {
if let Some(at) = lower.find(bad) {
let from = lower[..at]
.char_indices()
.rev()
.nth(40)
.map_or(0, |(i, _)| i);
let near: String = lower[from..].chars().take(100).collect();
found.push(format!("{what} contains {bad:?}: ...{near}..."));
}
}
let w = words(&lower);
for bad in NEVER_WORDS {
if w.iter().any(|x| x == bad) {
found.push(format!("{what} says {bad:?}"));
}
}
}
for (what, text) in &texts {
found.extend(helper_or_location(what, text));
}
assert!(found.is_empty(), "{}", found.join("\n"));
// The settings file may be named in schemas (its fields are edited
// there until settings move into the books, P1), but no tool's
// title or description, no refusal, the connect text and no
// handbook section names it: it is the file books.settings names.
let mut prose: Vec<(String, String)> = BooksServer::tool_router()
.list_all()
.iter()
.map(|t| {
(
format!("tool {}", t.name),
format!(
"{} {}",
t.title.as_deref().unwrap_or_default(),
t.description.as_deref().unwrap_or_default()
),
)
})
.collect();
prose.extend(
texts
.into_iter()
.filter(|(what, _)| !what.starts_with("tool ")),
);
for (what, text) in &prose {
assert!(
!text.to_lowercase().contains("wanderbooks.toml"),
"{what} names the settings file: {text}"
);
}
}
/// Tool descriptions are at most 700 characters (plan I-12). The
/// ones over it today are listed, each to be shortened in P5 when its
/// text moves into the handbook; no new one may join them, and one
/// that shrinks under the cap must leave the list.
#[test]
fn tool_descriptions_fit_700_characters() {
// Shorten in P5.
const OVER: &[&str] = &[
"books.export",
"books.export_cpa_package",
"books.import_bank_csv",
"books.reconcile",
"books.set_entry_party",
"books.set_w9",
"books.show",
"books.vendor_1099",
];
let mut over: Vec<String> = BooksServer::tool_router()
.list_all()
.iter()
.filter(|t| t.description.as_deref().unwrap_or_default().chars().count() > 700)
.map(|t| t.name.to_string())
.collect();
over.sort();
assert_eq!(over, OVER, "tool descriptions over 700 characters");
}
/// The tools docs/mcp-interface.md documents: a line (or a bullet)
/// that starts with `` `books.NAME` `` or `` `books.NAME { `` , and
/// any `` `books.NAME { `` signature within a line.
fn documented_tools(doc: &str) -> std::collections::BTreeSet<String> {
let name_at = |s: &str| -> Option<(String, char)> {
let rest = s.strip_prefix("`books.")?;
let name: String = rest
.chars()
.take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_')
.collect();
let next = rest[name.len()..].chars().next()?;
(!name.is_empty()).then(|| (format!("books.{name}"), next))
};
let mut out = std::collections::BTreeSet::new();
for line in doc.lines() {
let t = line.trim_start();
let t = t.strip_prefix("- ").unwrap_or(t);
if let Some((name, next)) = name_at(t)
&& (next == '`' || next == ' ')
{
out.insert(name);
}
for (i, _) in line.match_indices("`books.") {
if let Some((name, ' ')) = name_at(&line[i..])
&& line[i + 1 + name.len()..].starts_with(" {")
{
out.insert(name);
}
}
}
out
}
/// Every tool the server has is documented in docs/mcp-interface.md,
/// and every tool the doc documents is one the server has.
#[test]
fn every_tool_is_documented_and_every_documented_tool_exists() {
let doc = include_str!("../../../docs/mcp-interface.md");
let documented = documented_tools(doc);
let tools: std::collections::BTreeSet<String> = BooksServer::tool_router()
.list_all()
.iter()
.map(|t| t.name.to_string())
.collect();
let undocumented: Vec<&String> = tools.difference(&documented).collect();
let unknown: Vec<&String> = documented.difference(&tools).collect();
assert!(
undocumented.is_empty(),
"tools docs/mcp-interface.md doesn't document: {undocumented:?}"
);
assert!(
unknown.is_empty(),
"docs/mcp-interface.md documents tools there aren't: {unknown:?}"
);
// The reader catches what it should, and only that.
let sample = "- `books.chart` → x\n `books.decisions { a? }`; \
`books.open_items { b? }`.\nsee `books.cash_accounts`, \
`books.show`.";
let got: Vec<String> = documented_tools(sample).into_iter().collect();
assert_eq!(got, ["books.chart", "books.decisions", "books.open_items"]);
}
/// rules.md B-30 (defect 16): every report tool says what its omitted
/// dates mean in the one sentence the resolver's kind gives
/// (PeriodKind::describe), and each date parameter in the schema's
/// own words (START_DOC, END_DOC, AS_OF_DOC, YEAR_DOC); the two tools
/// over the description budget (books.export, books.show) and the CPA
/// package say it in their parameters. No tool or schema says "all
/// time", "beginning of time" or "first entry" any more. The resources
/// read with dates omitted say which period they cover.
#[test]
fn period_tools_describe_the_one_default() {
use ledger_core::calendar::{AS_OF_DOC, END_DOC, PeriodKind, START_DOC, YEAR_DOC};
let tools = BooksServer::tool_router().list_all();
let tool = |name: &str| {
tools
.iter()
.find(|t| t.name == name)
.unwrap_or_else(|| panic!("no tool {name}"))
};
let field = |name: &str, f: &str| -> String {
tool(name).input_schema["properties"][f]["description"]
.as_str()
.unwrap_or_else(|| panic!("{name}.{f} has no description"))
.to_string()
};
for (name, kind, fields) in [
(
"books.profit_and_loss",
PeriodKind::Statement,
&[("start", START_DOC), ("end", END_DOC)][..],
),
(
"books.profit_and_loss_by_class",
PeriodKind::Statement,
&[("start", START_DOC), ("end", END_DOC)][..],
),
(
"books.project",
PeriodKind::Statement,
&[("start", START_DOC), ("end", END_DOC)][..],
),
(
"books.cash_flow",
PeriodKind::Statement,
&[("start", START_DOC), ("end", END_DOC)][..],
),
(
"books.trial_balance",
PeriodKind::AsOf,
&[("as_of", AS_OF_DOC)][..],
),
(
"books.balance_sheet",
PeriodKind::AsOf,
&[("as_of", AS_OF_DOC)][..],
),
(
"books.control_check",
PeriodKind::AsOf,
&[("as_of", AS_OF_DOC)][..],
),
(
"books.documents",
PeriodKind::AsOf,
&[("as_of", AS_OF_DOC)][..],
),
(
"books.document",
PeriodKind::AsOf,
&[("as_of", AS_OF_DOC)][..],
),
(
"books.ar_aging",
PeriodKind::AsOf,
&[("as_of", AS_OF_DOC)][..],
),
(
"books.ap_aging",
PeriodKind::AsOf,
&[("as_of", AS_OF_DOC)][..],
),
(
"books.parties",
PeriodKind::CalendarYear,
&[("year", YEAR_DOC)][..],
),
(
"books.party",
PeriodKind::CalendarYear,
&[("year", YEAR_DOC)][..],
),
] {
let d = tool(name).description.as_deref().unwrap_or_default();
assert!(d.ends_with(kind.describe()), "{name}: {d}");
for (f, doc) in fields {
assert_eq!(field(name, f), *doc, "{name}.{f}");
}
}
// Over the budget, or the package: in the parameters' words.
for (name, f, doc) in [
("books.export", "start", START_DOC),
("books.export", "end", END_DOC),
("books.export", "as_of", AS_OF_DOC),
("books.show", "start", START_DOC),
("books.show", "end", END_DOC),
("books.export_cpa_package", "as_of", AS_OF_DOC),
] {
assert!(
field(name, f).ends_with(doc),
"{name}.{f}: {}",
field(name, f)
);
}
assert!(
tool("books.export")
.description
.as_deref()
.unwrap()
.contains("omitted: the fiscal year to date")
);
// Nothing left of the old defaults, anywhere.
for t in tools.iter() {
let text = format!(
"{} {}",
t.description.as_deref().unwrap_or_default(),
serde_json::Value::Object((*t.input_schema).clone())
)
.to_lowercase();
for stale in [
"all time",
"beginning of time",
"first entry",
"first posted entry",
] {
assert!(!text.contains(stale), "{} says {stale:?}", t.name);
}
}
let resources = resource_list();
let resource = |uri: &str| {
resources
.iter()
.find(|r| r.uri == uri)
.and_then(|r| r.description.clone())
.unwrap()
};
assert!(resource("books://reports/profit-and-loss").contains("fiscal year to date"));
assert!(resource("books://reports/trial-balance").contains("as of today"));
assert!(resource("books://reports/balance-sheet").contains("as of today"));
}
/// How many masked TINs a text holds.
fn data_masked(text: &str) -> usize {
text.matches("•••-••-••••").count() + text.matches("•••••••••").count()
}
/// redact_error (B-26): every full TIN in an error's message and data
/// is masked, wherever the data holds it (a string, in an array, in a
/// nested object), and what holds none is left as it was.
#[test]
fn redact_error_masks_every_tin_in_the_message_and_data() {
let tin = "291-12-2846";
let e = redact_error(rmcp::ErrorData::resource_not_found(
format!("books://receipts/{tin}"),
Some(serde_json::json!({
"reason": format!("bad receipt resource URI: {tin}"),
"list": ["plain", tin, ["deeper 291122846"]],
"nested": {"uri": tin, "n": 5, "fine": "no tin here 2026-10-16"},
})),
));
let text = format!("{} {}", e.message, e.data.as_ref().unwrap());
assert!(!text.contains(tin) && !text.contains("291122846"), "{text}");
assert_eq!(data_masked(&text), 5, "every TIN masked: {text}");
let data = e.data.unwrap();
assert_eq!(data["list"][0], "plain");
assert_eq!(data["nested"]["n"], 5);
assert_eq!(data["nested"]["fine"], "no tin here 2026-10-16");
}
/// Fail closed (SharedStore INVARIANTS 1-2): a request that panics,
/// with the store held or not, stops the server, and every later
/// request, tool or lock, is refused with the plain STOPPED message,
/// never the panic's text (which could carry anything, a TIN
/// included).
#[test]
fn a_request_that_panics_stops_the_server() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
type Body = fn(BooksServer) -> Result<u64, String>;
async fn run(server: &BooksServer, body: Body) -> Result<Result<u64, String>, rmcp::ErrorData> {
let s = server.clone();
run_blocking(&server.store, move || Box::pin(async move { body(s) })).await
}
let bodies: [(&str, Body); 2] = [
("holding the store", |s| {
let _store = s.lock()?;
panic!("mid-mutation 123-45-6789");
}),
("without the store", |_| {
panic!("before the lock 123-45-6789")
}),
];
for (how, body) in bodies {
let books = unit_dir("stops");
let parts = Served::open(store(&books)).unwrap();
let server = BooksServer::new(&parts);
rt.block_on(async {
let seq: Body = |s| s.lock().map(|st| st.journal_seq());
// New books: store.created, then their journal format.
assert_eq!(run(&server, seq).await.unwrap(), Ok(2), "{how}");
let died = run(&server, body).await.unwrap_err();
assert_eq!(died.message, STOPPED, "{how}");
// Every later request is refused, before it runs.
let after = run(&server, seq).await.unwrap_err();
assert_eq!(after.message, STOPPED, "{how}");
// So is any other session with the books.
let other = BooksServer::new(&parts);
assert_eq!(run(&other, seq).await.unwrap_err().message, STOPPED);
});
// And the lock itself, from any thread.
let s = server.clone();
let locked = std::thread::spawn(move || s.lock().map(|_| ()))
.join()
.unwrap();
assert_eq!(locked, Err(STOPPED.to_string()), "{how}");
}
// A guard dropped by a panic on a thread of its own stops it too.
let books = unit_dir("stops-thread");
let server = served(store(&books));
let s = server.clone();
let died = std::thread::spawn(move || {
let _store = s.lock().unwrap();
panic!("mid-mutation");
})
.join();
assert!(died.is_err());
assert_eq!(server.lock().map(|_| ()), Err(STOPPED.to_string()));
}
/// run_blocking drives a request to completion even when it waits: a
/// future that is pending until another thread wakes it completes,
/// with its own result (the waker unparks the request's thread).
#[test]
fn a_request_that_waits_is_woken_and_completes() {
/// Pending on its first poll, woken from another thread shortly
/// after; ready on the next.
struct WakeLater(bool);
impl std::future::Future for WakeLater {
type Output = u64;
fn poll(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<u64> {
if self.0 {
return std::task::Poll::Ready(7);
}
self.0 = true;
let waker = cx.waker().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(20));
waker.wake();
});
std::task::Poll::Pending
}
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let books = unit_dir("wakes");
let shared = SharedStore::new(store(&books));
let got = rt.block_on(async {
tokio::time::timeout(
std::time::Duration::from_secs(10),
run_blocking(&shared, || Box::pin(WakeLater(false))),
)
.await
});
// A request never woken would hold its thread: don't wait for it.
rt.shutdown_background();
assert_eq!(got.ok().and_then(Result::ok), Some(7), "never woken");
}
// Calls through the access door (access.rs), driven straight into the
// handler with a context the HTTP door would have left: its Caller,
// or none.
/// A peer to make request contexts with: a client and a server over
/// an in-memory pipe, kept alive while it is.
struct Session {
peer: rmcp::service::Peer<RoleServer>,
_server: rmcp::service::RunningService<RoleServer, BooksServer>,
_client: rmcp::service::RunningService<rmcp::service::RoleClient, ()>,
_books: TestDir,
}
async fn session(tag: &str) -> Session {
use rmcp::ServiceExt;
let books = unit_dir(&format!("{tag}-peer"));
let server = served(store(&books));
let (a, b) = tokio::io::duplex(1 << 16);
let served = tokio::spawn(async move { server.serve(a).await });
let client = ().serve(b).await.unwrap();
let server = served.await.unwrap().unwrap();
Session {
peer: server.peer().clone(),
_server: server,
_client: client,
_books: books,
}
}
/// A request's context: from a token with `scopes` (the door's
/// Caller), or with none.
fn context(
peer: &rmcp::service::Peer<RoleServer>,
scopes: Option<&[ledger_store::auth::Scope]>,
) -> RequestContext<RoleServer> {
let mut cx = RequestContext::new(rmcp::model::NumberOrString::Number(7), peer.clone());
if let Some(scopes) = scopes {
let token = ledger_store::auth::Token {
id: "tok_0123456789ab".into(),
book: "acme".into(),
name: "test".into(),
scopes: scopes.iter().copied().collect(),
created: "2026-10-20T00:00:00Z".into(),
sha256: String::new(),
};
let (mut parts, ()) = axum::http::Request::new(()).into_parts();
parts.extensions.insert(crate::access::Caller { token });
cx.extensions.insert(parts);
}
cx
}
const ALL: &[ledger_store::auth::Scope] = &[
ledger_store::auth::Scope::Read,
ledger_store::auth::Scope::Write,
ledger_store::auth::Scope::Post,
ledger_store::auth::Scope::Admin,
];
/// Books as the books server serves them: calls are checked against
/// their caller's token and recorded in the access log beside them.
fn logged(tag: &str) -> (BooksServer, TestDir) {
let books = unit_dir(tag);
let server = served(store(&books));
(server, books)
}
/// Books whose access log can't be written: its file is a FIFO no one
/// reads, so every record fails (as on a full disk).
fn unwritable(tag: &str) -> (BooksServer, TestDir) {
let books = unit_dir(tag);
let store = store(&books);
let fifo = books.0.join(ledger_store::access::ACCESS_FILE);
let made = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.unwrap();
assert!(made.success());
let path = fifo.clone();
let other_end = std::thread::spawn(move || {
// The log reads the file whole as it opens (empty: a writer
// that goes at once), then opens it to append (a reader that
// goes at once): nothing reads what is written after.
drop(std::fs::OpenOptions::new().write(true).open(&path).unwrap());
drop(std::fs::File::open(&path).unwrap());
});
let server = served(store);
other_end.join().unwrap();
(server, books)
}
/// The access log's records: (subject, outcome, whether it carries
/// the arguments' digest).
fn records(books: &TestDir) -> Vec<(String, ledger_store::access::Outcome, bool)> {
ledger_store::access::read(&books.0)
.unwrap()
.0
.into_iter()
.map(|r| {
(
r.access.subject,
r.access.result,
r.access.args_digest.is_some(),
)
})
.collect()
}
fn refused(code: &str) -> ledger_store::access::Outcome {
ledger_store::access::Outcome::Refused(code.into())
}
fn tool_call(name: &str, args: serde_json::Value) -> rmcp::model::CallToolRequestParams {
rmcp::model::CallToolRequestParams::new(name.to_string())
.with_arguments(args.as_object().cloned().unwrap_or_default())
}
/// A tool's answer: Ok((is it a refusal, its text)), or the protocol
/// error.
fn answer(
out: Result<rmcp::model::CallToolResponse, rmcp::ErrorData>,
) -> Result<(bool, String), rmcp::ErrorData> {
match out? {
rmcp::model::CallToolResponse::Complete(r) => Ok((
r.is_error == Some(true),
r.content
.iter()
.find_map(|c| c.as_text().map(|t| t.text.clone()))
.unwrap_or_default(),
)),
other => panic!("not a complete answer: {other:?}"),
}
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
}
/// access.rs INVARIANTS 1-3: a call that reaches the books without a
/// Caller, or names a tool no table knows, is refused unrun (fail
/// closed), told why in words, and recorded so.
#[test]
fn a_call_without_a_caller_or_to_no_tool_is_refused_unrun_and_recorded() {
rt().block_on(async {
let s = session("door").await;
let (server, books) = logged("door");
let add = || {
tool_call(
"books.add_account",
serde_json::json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
)
};
let (refusal, text) =
answer(server.call_tool(add(), context(&s.peer, None)).await).unwrap();
assert!(refusal, "{text}");
assert!(text.contains("without a token"), "{text}");
// An unknown tool is a protocol error (mcp-tools.md), never run.
let unknown = tool_call("books.no_such_tool", serde_json::json!({}));
let err = server
.call_tool(unknown, context(&s.peer, Some(ALL)))
.await
.unwrap_err();
assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS, "{err:?}");
assert!(err.message.contains("there is no tool"), "{err:?}");
// Neither ran: the account isn't there.
let chart = tool_call("books.chart", serde_json::json!({}));
let (refusal, text) =
answer(server.call_tool(chart, context(&s.peer, Some(ALL))).await).unwrap();
assert!(!refusal, "{text}");
assert!(!text.contains("Cash"), "{text}");
assert_eq!(
records(&books),
vec![
("books.add_account".into(), refused("unauthorized"), true),
("books.no_such_tool".into(), refused("unknown_tool"), true),
(
"books.chart".into(),
ledger_store::access::Outcome::Ok,
true
),
]
);
});
}
/// SharedStore INVARIANT 3: once the books are closing a call or a
/// read isn't run, and says so with the not-run code (a JSON-RPC
/// server error, which the agents' bridge sends again once the books
/// are back); each is recorded as refused.
#[test]
fn a_call_while_the_books_close_is_refused_as_not_run() {
rt().block_on(async {
let s = session("closing").await;
let (server, books) = logged("closing");
server.store.close().await;
let chart = tool_call("books.chart", serde_json::json!({}));
let e = server
.call_tool(chart, context(&s.peer, Some(ALL)))
.await
.unwrap_err();
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
assert_eq!(e.message, NOT_RUN_CLOSING);
// JSON-RPC 2.0 5.1: -32000 to -32099 is for the server's own
// errors.
assert!((-32099..=-32000).contains(&NOT_RUN_CLOSING_CODE));
assert!((-32099..=-32000).contains(&NOT_RUN_STOPPED_CODE));
let read = ReadResourceRequestParams::new("books://chart");
let e = server
.read_resource(read, context(&s.peer, Some(ALL)))
.await
.unwrap_err();
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
assert_eq!(
records(&books),
vec![
("books.chart".into(), refused("closing"), true),
("books://chart".into(), refused("closing"), false),
]
);
});
}
/// Reading a resource is reading the books (access.rs): only a token
/// with books:read reads one, and every read is recorded with what
/// came of it.
#[test]
fn a_resource_is_read_only_with_books_read_and_each_read_is_recorded() {
use ledger_store::access::Outcome;
rt().block_on(async {
let s = session("resources").await;
let (server, books) = logged("resources");
let read = |uri: &str| ReadResourceRequestParams::new(uri);
assert!(
server
.read_resource(read("books://chart"), context(&s.peer, Some(ALL)))
.await
.is_ok()
);
let writer = [ledger_store::auth::Scope::Write];
let e = server
.read_resource(read("books://chart"), context(&s.peer, Some(&writer[..])))
.await
.unwrap_err();
assert_eq!(e.message, crate::access::INSUFFICIENT_SCOPE);
let e = server
.read_resource(read("books://chart"), context(&s.peer, None))
.await
.unwrap_err();
assert_eq!(e.message, crate::access::UNAUTHORIZED);
// One that ran and failed is an error, not a refusal.
assert!(
server
.read_resource(read("books://nope"), context(&s.peer, Some(ALL)))
.await
.is_err()
);
let outcomes: Vec<_> = records(&books).into_iter().map(|r| r.1).collect();
assert_eq!(
outcomes,
vec![
Outcome::Ok,
refused("insufficient_scope"),
refused("unauthorized"),
Outcome::Error,
]
);
});
}
/// SharedStore INVARIANTS 2-3 at the last moment: a call that passed
/// the checks, then found the books closing (or the server stopped)
/// as it went to run, isn't run either, and is answered and recorded
/// as one refused at the start would be. The settings are held so
/// the call waits between its checks and its run.
#[test]
fn a_call_the_books_close_or_stop_under_is_refused_as_not_run() {
let rt = rt();
for (how, stops) in [("closes", false), ("stops", true)] {
let (server, books) = logged(&format!("late-{how}"));
// The settings, held on a thread of their own until released.
let (locked, is_locked) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel::<()>();
let settings = server.settings.clone();
let holder = std::thread::spawn(move || {
let _held = settings.lock().unwrap();
locked.send(()).unwrap();
let _ = released.recv();
});
is_locked.recv().unwrap();
let answered = rt.block_on(async {
let s = session(&format!("late-{how}")).await;
let (started, began) = tokio::sync::oneshot::channel();
let calling = server.clone();
let cx = context(&s.peer, Some(ALL));
let call = tokio::spawn(async move {
let _ = started.send(());
let chart = tool_call("books.chart", serde_json::json!({}));
calling.call_tool(chart, cx).await
});
began.await.unwrap();
// Past its checks (no I/O between): waiting for the settings.
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if stops {
server.store.stop();
} else {
server
.store
.closing
.store(true, std::sync::atomic::Ordering::SeqCst);
}
release.send(()).unwrap();
call.await.unwrap()
});
holder.join().unwrap();
if stops {
assert_eq!(answer(answered).unwrap(), (true, STOPPED.to_string()));
// Refused after its arguments were taken: their digest too.
assert_eq!(
records(&books),
vec![("books.chart".into(), refused("stopped"), true)]
);
} else {
let e = answered.unwrap_err();
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE, "{e:?}");
// Its arguments went with the request: no digest.
assert_eq!(
records(&books),
vec![("books.chart".into(), refused("closing"), false)]
);
}
}
}
/// access.rs INVARIANT 3: a record that can't be written stops the
/// books, and the answer says so; a call or read refused as not run
/// (closing) keeps its not-run code, so the bridge still sends it
/// again; anything else is an error in words.
#[test]
fn an_access_record_that_cant_be_written_stops_the_books_and_says_so() {
rt().block_on(async {
let s = session("unrecorded").await;
// A call refused as the books close.
let (server, _books) = unwritable("unrecorded-call");
server.store.close().await;
let chart = tool_call("books.chart", serde_json::json!({}));
let e = server
.call_tool(chart, context(&s.peer, Some(ALL)))
.await
.unwrap_err();
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}");
assert!(server.store.stopped());
// Any other answer refused unrun becomes the words alone.
let (server, _books) = unwritable("unrecorded-refusal");
let call = crate::access::Call::new(
&server.access,
&context(&s.peer, Some(ALL)),
"tool",
"books.chart",
);
let out = server
.refuse(
&call,
crate::access::STOPPED,
Err(rmcp::ErrorData::internal_error("not run", None)),
)
.await;
assert_eq!(
answer(out).unwrap(),
(true, crate::access::NOT_RECORDED.to_string())
);
assert!(server.store.stopped());
// A read refused as the books close.
let (server, _books) = unwritable("unrecorded-closing-read");
server.store.close().await;
let e = server
.read_resource(
ReadResourceRequestParams::new("books://chart"),
context(&s.peer, Some(ALL)),
)
.await
.unwrap_err();
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}");
assert!(server.store.stopped());
// A read that ran and failed.
let (server, _books) = unwritable("unrecorded-read");
let e = server
.read_resource(
ReadResourceRequestParams::new("books://nope"),
context(&s.peer, Some(ALL)),
)
.await
.unwrap_err();
assert_eq!(e.code, rmcp::model::ErrorCode::INTERNAL_ERROR);
assert_eq!(e.message, crate::access::NOT_RECORDED);
assert!(server.store.stopped());
});
}
/// A request whose blocking work the runtime never ran (it was
/// shutting down) is answered as not run, in the words a stopped
/// server uses, and stops nothing: only a request that panicked
/// stops the books (run_blocking).
#[test]
fn a_request_the_runtime_never_ran_is_answered_not_run_and_stops_nothing() {
let dead = rt();
let handle = dead.handle().clone();
drop(dead);
rt().block_on(async {
let s = session("never-ran").await;
let (server, _books) = logged("never-ran");
let chart = tool_call("books.chart", serde_json::json!({}));
let mut call = std::pin::pin!(server.call_tool(chart, context(&s.peer, Some(ALL))));
// Its blocking work goes to the runtime that shut down.
let out = std::future::poll_fn(|cx| {
let _in = handle.enter();
call.as_mut().poll(cx)
})
.await;
assert_eq!(answer(out).unwrap(), (true, STOPPED.to_string()));
assert!(!server.store.stopped());
});
}
/// A tool list differs by caller (scopes.rs): a client on 2026-07-28
/// or later is told to cache it for this caller alone, and not to keep
/// it; an older one, which knows neither hint, is told neither.
#[test]
fn a_tool_list_is_cached_only_for_its_caller() {
rt().block_on(async {
let s = session("hints").await;
let books = unit_dir("hints");
let server = served(store(&books));
for (version, hinted) in [
(rmcp::model::ProtocolVersion::V_2026_07_28, true),
(rmcp::model::ProtocolVersion::V_2025_11_25, false),
] {
let mut cx = context(&s.peer, Some(ALL));
cx.meta.set_protocol_version(version.clone());
let list = server.list_tools(None, cx).await.unwrap();
assert!(!list.tools.is_empty());
assert_eq!(list.ttl_ms, hinted.then_some(0), "{version:?}");
assert_eq!(
list.cache_scope,
hinted.then_some(rmcp::model::CacheScope::Private),
"{version:?}"
);
}
});
}