Files
wanderbooks/crates/ledger-mcp/tests/hostile_args.rs
T
swaitsandClaude Opus 5.5 a833229d87 tests: every test dir is removed when its test ends, pass or panic
Many test helpers removed their temp dir only before the test, or only
on pass, so runs left them behind: 117,000 filled /tmp (RAM here) and
stopped a mutation run. Each now holds a guard that removes the dir on
drop, unwind included; servers started on a test dir hold its guard
until they stop. A full cargo test --workspace leaves 0 dirs (859
before). just sweep-test-tmp stays for runs killed mid-test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 20:47:44 -06:00

1540 lines
53 KiB
Rust

//! Hostile input never stops the server (SharedStore, server.rs: a request
//! that panics stops the books server for every session, so a panic an
//! input can reach is a denial of service).
//!
//! ```text
//! GOAL: no argument, resource URI or combination of them reaches a panic,
//! an overflow, or an internal error: every call is answered, with a
//! result or a refusal, and the server keeps serving.
//! SOURCE: DECISION (P0 review round 3, 2026-10-16, finding S1): fail
//! closed makes every reachable panic a denial of service, so none
//! may be reachable.
//! INVARIANTS (oracle):
//! 1. Every tool in tools/list, called with arguments generated from its
//! input schema and then made hostile (wrong types, missing or extra
//! fields, empty, huge, negative and fractional numbers, u64::MAX,
//! decimal strings with 30+ digits or absurd exponents, impossible and
//! far-off dates and periods, control and format characters, long
//! strings), gets a JSON-RPC answer that is no internal error, and a
//! tool result that never says the server stopped.
//! 2. After every call the server still serves (books.chart answers).
//! 3. The same for resources/read with generated URIs.
//! 4. After them, the books reopen: a restarted server answers
//! books.chart and books.verify (P0 review round 4: a call a command
//! accepts but replay refuses would brick the books).
//! 5. Books an earlier version wrote (ledger-store's old-books fixtures)
//! open, and every tool and resource answers over them.
//! ```
//!
//! Against a small fixture company with every kind of record, so ids and
//! names the generator picks often exist. `MCP_CASES` sets the number of
//! cases (default 256).
use proptest::prelude::*;
use proptest::test_runner::{Config, TestCaseError, TestRunner};
use serde_json::{Map, Value, json};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::{Receiver, channel};
use std::time::Duration;
/// The longest any one call may take: a call that hangs is a denial of
/// service too.
const TIMEOUT: Duration = Duration::from_secs(120);
/// What every request gets once the server stopped (server.rs STOPPED).
const STOPPED: &str = "stopped taking requests";
fn cases() -> u32 {
std::env::var("MCP_CASES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(256)
}
struct Mcp {
stdin: ChildStdin,
rx: Receiver<String>,
child: Child,
next_id: u64,
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// The places one run uses: the books, the vault key, the import root, the
/// folder the server runs in (where a relative path lands).
struct Places {
base: PathBuf,
books: PathBuf,
inbox: PathBuf,
sandbox: PathBuf,
}
/// The places go when the test ends, pass or panic.
impl Drop for Places {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
fn places(tag: &str) -> Places {
let base =
std::env::temp_dir().join(format!("ledger-mcp-hostile-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let p = Places {
books: base.join("books"),
inbox: base.join("inbox"),
sandbox: base.join("sandbox"),
base,
};
for d in [
&p.books,
&p.inbox,
&p.sandbox.join("out"),
&p.base.join("home"),
&p.base.join("config"),
] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(
p.books.join("wanderbooks.toml"),
format!(
"[company]\nname = \"Hostile Co\"\nfiscal_year_start = \"07-01\"\ntax_form = \"1065\"\n\
[[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
[books]\nreceipt_threshold = \"25.00\"\ncash_accounts = [\"1000\"]\nparties = \"suggest\"\n\
[vault]\nkey_file = \"{}\"\n",
p.base.join("config/vault.key").display()
),
)
.unwrap();
std::fs::write(p.inbox.join("receipt.pdf"), b"%PDF-1.4 a receipt\n%%EOF\n").unwrap();
std::fs::write(
p.inbox.join("bank.csv"),
"Date,Description,Amount\n2026-03-01,Coffee,-4.50\n2026-03-02,Deposit,100.00\n",
)
.unwrap();
p
}
impl Mcp {
fn start(p: &Places) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
.arg("--data-dir")
.arg(p.books.join("books-data"))
.arg("--books-toml")
.arg(p.books.join("wanderbooks.toml"))
.arg("--import-root")
.arg(&p.inbox)
.current_dir(&p.sandbox)
.env("HOME", p.base.join("home"))
.env("XDG_CONFIG_HOME", p.base.join("config"))
.env("XDG_RUNTIME_DIR", p.base.join("config"))
.env_remove("XDG_DOWNLOAD_DIR")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::from(
std::fs::File::create(p.base.join("stderr.log")).unwrap(),
))
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let (tx, rx) = channel();
std::thread::spawn(move || {
for line in stdout.lines() {
let Ok(line) = line else { break };
if tx.send(line).is_err() {
break;
}
}
});
let mut m = Self {
stdin,
rx,
child,
next_id: 1,
};
let init = m.rpc(
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "hostile", "version": "0"}}),
);
assert!(init.get("result").is_some(), "{init}");
writeln!(
m.stdin,
"{}",
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
)
.unwrap();
m
}
/// One request and its answer (Err: none came within TIMEOUT).
fn rpc(&mut self, method: &str, params: Value) -> Value {
self.try_rpc(method, params)
.unwrap_or_else(|e| panic!("{method}: {e}"))
}
fn try_rpc(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
writeln!(self.stdin, "{msg}").map_err(|e| format!("write: {e}"))?;
self.stdin.flush().map_err(|e| format!("flush: {e}"))?;
loop {
let line = self
.rx
.recv_timeout(TIMEOUT)
.map_err(|_| format!("no answer within {TIMEOUT:?} (or the server died)"))?;
let v: Value =
serde_json::from_str(&line).map_err(|e| format!("non-JSON {line:?}: {e}"))?;
if v.get("id") == Some(&json!(id)) {
return Ok(v);
}
}
}
/// A tool's structured result; panics on any refusal (fixture setup).
fn ok(&mut self, tool: &str, args: Value) -> Value {
let r = self.rpc("tools/call", json!({"name": tool, "arguments": args}));
assert!(
r.get("error").is_none() && r["result"]["isError"] != json!(true),
"fixture {tool} {args}: {r}"
);
r["result"]["structuredContent"].clone()
}
}
/// A small company with one of everything, so generated ids and names
/// often find a record.
fn fixture(m: &mut Mcp, p: &Places) {
for (code, name, ty) in [
("1000", "Bank", "asset"),
("1200", "Receivables", "asset"),
("1300", "Prepaid", "asset"),
("1500", "Equipment", "asset"),
("1501", "Accumulated Depreciation", "asset"),
("2000", "Payables", "liability"),
("2050", "Card", "liability"),
("2400", "Deferred Revenue", "liability"),
("3000", "Capital A", "equity"),
("3001", "Capital B", "equity"),
("4000", "Revenue", "revenue"),
("4900", "Gain", "revenue"),
("6000", "Contract Labor", "expense"),
("6100", "Depreciation", "expense"),
("6300", "Insurance", "expense"),
("6999", "General", "expense"),
] {
m.ok(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
);
}
m.ok(
"books.add_party",
json!({"name": "Acme", "roles": ["contractor"]}),
);
m.ok("books.add_vendor", json!({"name": "Acme"}));
m.ok("books.add_customer", json!({"name": "Client Co"}));
m.ok(
"books.add_party",
json!({"name": "Dana", "roles": ["employee"]}),
);
m.ok(
"books.add_party",
json!({"name": "Member A", "roles": ["member"]}),
);
// An unlinked receipt (attachment 1).
m.ok(
"books.attach_receipt",
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQgYSByZWNlaXB0"}),
);
for (date, dr, cr, amount) in [
("2026-01-02", "1000", "3000", "5000.00"),
("2026-01-02", "1000", "3001", "5000.00"),
("2026-02-10", "6000", "1000", "1200.00"),
("2026-03-05", "1500", "2050", "2400.00"),
] {
let id = m.ok(
"books.draft_entry",
json!({"date": date, "description": format!("{dr} from {cr}")}),
)["entry_id"]
.clone();
for (side, account) in [("debit", dr), ("credit", cr)] {
m.ok(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
let pdf = format!("%PDF-1.4 receipt for {date} {dr} {cr}");
m.ok(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64(pdf.as_bytes())}),
);
m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
// A draft left open.
let draft = m.ok(
"books.draft_entry",
json!({"date": "2026-04-01", "description": "open draft"}),
)["entry_id"]
.clone();
m.ok(
"books.add_line",
json!({"entry_id": draft, "account": "6999", "side": "debit", "amount": "10.00"}),
);
m.ok(
"books.register_asset",
json!({"description": "Laptop", "purchase_date": "2026-03-05", "cost": "2400.00",
"useful_life_months": 24, "cost_account": "1500",
"accum_depr_account": "1501", "expense_account": "6100"}),
);
m.ok(
"books.create_schedule",
json!({"kind": "prepaid_expense", "description": "Insurance", "total": "300.00",
"start": "2026-01-01", "months": 3, "balance_account": "1300",
"pl_account": "6300"}),
);
m.ok(
"books.create_recurring",
json!({"description": "Rent", "frequency": "monthly", "start": "2026-01-01",
"lines": [{"account": "6999", "side": "debit", "amount": "50.00"},
{"account": "1000", "side": "credit", "amount": "50.00"}]}),
);
m.ok(
"books.set_tin",
json!({"party": "Member A", "tin": "638-27-4915", "tin_type": "ssn"}),
);
m.ok(
"books.file_document",
json!({"category": "statement", "file_name": "s.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQgYSBzdGF0ZW1lbnQ="}),
);
let _ = p;
}
// ------------------------------------------------------------- generators
/// Strings that break naive parsers: dates, periods, decimals, ids,
/// control and format characters, the long and the empty.
fn hostile_strings() -> Vec<String> {
let mut v: Vec<String> = [
"",
" ",
"0",
"-0",
"1",
"-1",
"2",
"3",
"18446744073709551615",
"18446744073709551616",
"-9223372036854775808",
"0.0000000000000000000000000001",
"79228162514264337593543950335",
"-79228162514264337593543950335",
"79228162514264337593543950336",
"999999999999999.9999999999",
"1000000000000000",
"1000000000000001",
"123456789012345678901234567890.12",
"1e28",
"1E-30",
"1e999999999",
"NaN",
"inf",
"1.5",
"-12.50",
"12.345678901234567",
"0001-01-01",
"0000-00-00",
"9999-12-31",
"99999-12-31",
"+262142-12-31",
"-262143-01-01",
"+262142-06-30",
"2026-02-30",
"2026-02-29",
"2028-02-29",
"2026-13-01",
"2026-00-10",
" 2026-01-15",
"2026-01-15 ",
"2026-1-5",
"2026-10-16",
"2026-12-31",
"2025-12-31",
"2026-06-30",
"999999-01",
"262142-12",
"262143-01",
"0000-01",
"0-1",
"2026-12",
"2026-02",
"2026-13",
"-2026-01",
"2026-01-01T00:00:00Z",
"1000",
"1500",
"6000",
"3000",
"4000",
"9999",
"Acme",
"Dana",
"Member A",
"Client Co",
"Nobody",
"WL-0001",
"1/1/2026",
"\u{0}",
"a\u{0}b",
"\n\n\r\t",
"\u{202e}evil\u{200b}",
"\u{feff}1000",
"1000",
"571-93-2846",
"638274915",
"../../../../etc/passwd",
"books://chart",
"%s%n%x",
"{\"a\":1}",
"null",
"true",
"debit",
"credit",
"asset",
"expense",
"monthly",
"weekly",
"yearly",
"usd",
"EUR",
"XXX",
"ssn",
"ein",
"nec",
"misc",
"cost",
"carryover",
"personal_use_conversion",
"prepaid_expense",
"deferred_revenue",
"receipt",
"document",
"statement",
"csv",
"xlsx",
"pdf",
"json",
"trial_balance",
"balance_sheet",
"profit_and_loss",
"fixed_asset_register",
"general_ledger",
"month",
"quarter",
"year",
"entry",
"account",
"company",
"all",
"contract",
]
.iter()
.map(|s| s.to_string())
.collect();
v.push("9".repeat(40));
v.push(format!("0.{}", "1".repeat(40)));
v.push("A".repeat(5000));
v.push("é".repeat(3000));
v.push("\u{7}".repeat(100));
v.push("2026-01-01".repeat(50));
v
}
/// Numbers that break naive arithmetic.
fn hostile_numbers() -> Vec<Value> {
vec![
json!(0),
json!(1),
json!(2),
json!(3),
json!(4),
json!(5),
json!(7),
json!(12),
json!(24),
json!(100),
json!(-1),
json!(i64::MIN),
json!(i64::MAX),
json!(u64::MAX),
json!(u32::MAX),
json!(u32::MAX as u64 + 1),
json!(i32::MAX),
json!(9_007_199_254_740_993u64),
json!(1.5),
json!(-0.0),
json!(1e300),
json!(-1e300),
json!(2026),
json!(9999),
json!(10000),
json!(262142),
json!(1_000_000),
]
}
/// Paths a tool may be given: inside the sandbox, the import root, the
/// books, relative ones, odd ones. Never anywhere a real file lives.
fn hostile_paths(p: &Places) -> Vec<String> {
let s = |p: &Path| p.to_str().unwrap().to_string();
vec![
s(&p.sandbox.join("out")),
s(&p.sandbox.join("out/new.zip")),
s(&p.sandbox.join("out/new.csv")),
s(&p.sandbox.join("out/new.pdf")),
s(&p.sandbox.join("out/missing-dir/x.zip")),
s(&p.inbox.join("receipt.pdf")),
s(&p.inbox.join("bank.csv")),
s(&p.inbox.join("missing.pdf")),
s(&p.inbox.join("x.zip")),
format!("{}/../inbox/receipt.pdf", s(&p.inbox)),
format!("{}/./receipt.pdf", s(&p.inbox)),
s(&p.books.join("wanderbooks.toml")),
s(&p.books.join("books-data/journal.jsonl")),
s(&p.books.join("out.zip")),
s(&p.base.join("config/vault.key")),
"relative.pdf".into(),
"out/rel.zip".into(),
String::new(),
"\u{0}".into(),
format!("{}/{}", s(&p.sandbox.join("out")), "n".repeat(300)),
]
}
/// What a JSON value of any type can be, hostile: the wrong-type filler.
fn any_value(p: &Places) -> BoxedStrategy<Value> {
let strings = hostile_strings();
let numbers = hostile_numbers();
let paths = hostile_paths(p);
prop_oneof![
proptest::sample::select(strings).prop_map(Value::String),
proptest::sample::select(numbers),
any::<bool>().prop_map(Value::Bool),
Just(Value::Null),
Just(json!([])),
Just(json!({})),
Just(json!(["1000", 1, null])),
proptest::sample::select(paths).prop_map(Value::String),
"\\PC{0,24}".prop_map(Value::String),
]
.boxed()
}
/// A field's value, from its schema node, often hostile.
fn value_for(
name: &str,
node: &Value,
defs: &Map<String, Value>,
p: &Places,
depth: u32,
) -> BoxedStrategy<Value> {
// Follow a reference; take any of anyOf's branches.
if let Some(r) = node.get("$ref").and_then(Value::as_str) {
let key = r.trim_start_matches("#/$defs/");
let target = defs.get(key).cloned().unwrap_or(json!({}));
return value_for(name, &target, defs, p, depth);
}
if let Some(branches) = node.get("anyOf").and_then(Value::as_array) {
let parts: Vec<BoxedStrategy<Value>> = branches
.iter()
.map(|b| value_for(name, b, defs, p, depth))
.collect();
return proptest::strategy::Union::new(parts).boxed();
}
let types: Vec<String> = match node.get("type") {
Some(Value::String(t)) => vec![t.clone()],
Some(Value::Array(ts)) => ts
.iter()
.filter_map(|t| t.as_str().map(str::to_string))
.collect(),
_ => vec![],
};
let mut parts: Vec<BoxedStrategy<Value>> = Vec::new();
for t in &types {
let s = match t.as_str() {
"string" => string_for(name, node, p),
"integer" | "number" => proptest::sample::select(hostile_numbers()).boxed(),
"boolean" => prop_oneof![3 => Just(json!(true)), 1 => Just(json!(false))].boxed(),
"null" => Just(Value::Null).boxed(),
"array" if depth < 3 => {
let item = node.get("items").cloned().unwrap_or(json!({}));
let inner = value_for(name, &item, defs, p, depth + 1);
prop_oneof![
8 => proptest::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
1 => proptest::collection::vec(inner, 40..60).prop_map(Value::Array),
]
.boxed()
}
"object" if depth < 3 => object_for(node, defs, p, depth + 1),
_ => any_value(p),
};
parts.push(s);
}
if parts.is_empty() {
return any_value(p);
}
let typed = proptest::strategy::Union::new(parts).boxed();
// Mostly the right type; sometimes any type at all.
prop_oneof![6 => typed, 1 => any_value(p)].boxed()
}
/// A string field's value: what its name suggests, and the hostile set.
fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
let n = name.to_lowercase();
let mut likely: Vec<String> = Vec::new();
if let Some(e) = node.get("enum").and_then(Value::as_array) {
likely.extend(e.iter().filter_map(|v| v.as_str().map(str::to_string)));
}
if n.contains("path") || n.starts_with("save") {
return proptest::sample::select(hostile_paths(p))
.prop_map(Value::String)
.boxed();
}
if n.contains("base64") {
likely.extend(
[
"JVBERi0xLjQ=",
"",
"!!!not base64",
"AAAA",
"NTcxLTkzLTI4NDY=",
"RGF0ZSxBbW91bnQKMjAyNi0wMS0wMSwxCg==",
]
.map(String::from),
);
}
let pick = |pred: fn(&str) -> bool| -> Vec<String> {
hostile_strings().into_iter().filter(|s| pred(s)).collect()
};
let date_like = |s: &str| {
s.len() >= 8 && s.chars().filter(|c| *c == '-').count() >= 2
|| s.is_empty()
|| s.starts_with(' ')
};
let period_like = |s: &str| s.matches('-').count() == 1 || s.is_empty();
let number_like = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E'))
&& s.matches('-').count() <= 1
};
let dates = [
"date",
"as_of",
"start",
"end",
"through",
"since",
"received_on",
"in_service",
];
let amounts = [
"amount",
"total",
"cost",
"deposit",
"proceeds",
"rate",
"percent",
"threshold",
"balance",
"basis",
"salvage",
"gross",
"withholding",
"ss_",
"medicare",
"deductions",
];
if n.contains("period") && !n.contains("period_") {
likely.extend(pick(period_like));
} else if dates.iter().any(|d| n.contains(d)) {
likely.extend(pick(date_like));
} else if amounts.iter().any(|a| n.contains(a)) {
likely.extend(pick(number_like));
}
let strings = hostile_strings();
let named = if likely.is_empty() {
proptest::sample::select(strings.clone()).boxed()
} else {
proptest::sample::select(likely).boxed()
};
prop_oneof![
3 => named.prop_map(Value::String),
4 => proptest::sample::select(strings).prop_map(Value::String),
1 => "\\PC{0,40}".prop_map(Value::String),
]
.boxed()
}
/// An object's value: each property present or not (required ones mostly
/// present), and now and then a field no schema names.
fn object_for(
node: &Value,
defs: &Map<String, Value>,
p: &Places,
depth: u32,
) -> BoxedStrategy<Value> {
let props = node
.get("properties")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let required: Vec<String> = node
.get("required")
.and_then(Value::as_array)
.map(|r| {
r.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let fields: Vec<BoxedStrategy<Option<(String, Value)>>> = props
.iter()
.map(|(k, v)| {
let key = k.clone();
let present = if required.contains(k) { 0.92 } else { 0.5 };
proptest::option::weighted(present, value_for(k, v, defs, p, depth))
.prop_map(move |o| o.map(|v| (key.clone(), v)))
.boxed()
})
.collect();
let extra = proptest::option::weighted(0.05, any_value(p));
(fields, extra)
.prop_map(|(fields, extra)| {
let mut m = Map::new();
for (k, v) in fields.into_iter().flatten() {
m.insert(k, v);
}
if let Some(x) = extra {
m.insert("unexpected_field".into(), x);
}
Value::Object(m)
})
.boxed()
}
/// A tool's arguments: an object from its schema, rarely not an object.
fn args_for(schema: &Value, p: &Places) -> BoxedStrategy<Value> {
let defs = schema
.get("$defs")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
prop_oneof![
30 => object_for(schema, &defs, p, 0),
1 => any_value(p),
]
.boxed()
}
/// INVARIANT 1-2 for one answer: no internal error, no "stopped", and the
/// server still serves.
fn check_answer(m: &mut Mcp, what: &str, r: Result<Value, String>) -> Result<(), TestCaseError> {
let r = r.map_err(|e| TestCaseError::fail(format!("{what}: {e}")))?;
if let Some(e) = r.get("error") {
prop_assert_ne!(
e["code"].as_i64(),
Some(-32603),
"{} got an internal error: {}",
what,
e
);
}
let text = r.to_string();
prop_assert!(
!text.contains(STOPPED),
"{} stopped the server: {}",
what,
text
);
prop_assert!(!text.contains("panicked"), "{} panicked: {}", what, text);
let chart = m
.try_rpc(
"tools/call",
json!({"name": "books.chart", "arguments": {}}),
)
.map_err(|e| TestCaseError::fail(format!("after {what}: {e}")))?;
prop_assert!(
chart.get("error").is_none() && chart["result"]["isError"] != json!(true),
"after {}, books.chart fails: {}",
what,
chart
);
Ok(())
}
/// INVARIANTS 1-2: every tool, hostile arguments, the server serves on.
#[test]
fn hostile_tool_arguments_never_stop_the_server() {
let p = places("tools");
let mut m = Mcp::start(&p);
fixture(&mut m, &p);
let list = m.rpc("tools/list", json!({}));
let tools: Vec<(String, Value)> = list["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| {
(
t["name"].as_str().unwrap().to_string(),
t["inputSchema"].clone(),
)
})
.collect();
assert!(tools.len() > 90, "{} tools", tools.len());
let per_tool: Vec<BoxedStrategy<(String, Value)>> = tools
.iter()
.map(|(name, schema)| {
let name = name.clone();
args_for(schema, &p)
.prop_map(move |a| (name.clone(), a))
.boxed()
})
.collect();
let call = proptest::strategy::Union::new(per_tool);
// No shrinking: the server is shared, so once one call stops it
// every smaller case fails too; the case reported is the first.
let mut runner = TestRunner::new(Config {
cases: cases(),
failure_persistence: None,
max_shrink_iters: 0,
..Config::default()
});
let m = std::cell::RefCell::new(m);
// How the calls were answered: (reached a tool, the tool succeeded),
// so the property can't pass by never getting past the schema.
let counts = std::cell::Cell::new((0u32, 0u32, 0u32));
runner
.run(&call, |(tool, args)| {
let mut m = m.borrow_mut();
let what = format!("{tool} {}", truncated(&args));
let r = m.try_rpc("tools/call", json!({"name": tool, "arguments": args}));
if let Ok(v) = &r {
let (n, reached, ok) = counts.get();
let got = v.get("error").is_none();
let fine = got && v["result"]["isError"] != json!(true);
counts.set((n + 1, reached + u32::from(got), ok + u32::from(fine)));
}
check_answer(&mut m, &what, r)
})
.unwrap();
let (n, reached, ok) = counts.get();
eprintln!("{n} calls: {reached} reached a tool, {ok} succeeded");
assert!(
reached * 2 >= n,
"most calls must reach a tool: {reached} of {n}"
);
assert!(ok * 20 >= n, "some calls must succeed: {ok} of {n}");
// Every tool is called at least once, whatever the cases drew.
let all = tools.clone();
let mut m = m.into_inner();
let mut runner = TestRunner::deterministic();
for (tool, schema) in &all {
let args = args_for(schema, &p)
.new_tree(&mut runner)
.unwrap()
.current();
let what = format!("{tool} {}", truncated(&args));
let r = m.try_rpc("tools/call", json!({"name": tool, "arguments": args}));
check_answer(&mut m, &what, r).unwrap();
}
reopens(m, &p, "every tool");
let _ = std::fs::remove_dir_all(&p.base);
}
/// INVARIANT 4: whatever the calls journaled, the books open again. A
/// hostile call a command accepts but replay refuses would leave books
/// that never reopen (rules.md B-28): the server is restarted on them and
/// must answer books.chart and books.verify.
fn reopens(m: Mcp, p: &Places, after: &str) {
drop(m);
let mut m = Mcp::start(p);
for tool in ["books.chart", "books.verify"] {
let r = m
.try_rpc("tools/call", json!({"name": tool, "arguments": {}}))
.unwrap_or_else(|e| panic!("after {after}, restarted: {tool}: {e}"));
assert!(
r.get("error").is_none() && r["result"]["isError"] != json!(true),
"after {after}, the books don't reopen: {tool}: {r}"
);
}
}
/// The id of the party named `name`.
fn party_id(m: &mut Mcp, name: &str) -> Value {
let parties = m.ok("books.parties", json!({}));
parties["parties"]
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == name)
.unwrap_or_else(|| panic!("no party {name}: {parties}"))["party_id"]
.clone()
}
/// A pinned call: it must reach the tool (no JSON-RPC error at all, so a
/// mistyped argument can't make the check vacuous), then INVARIANTS 1-2.
fn pinned(m: &mut Mcp, tool: &str, args: Value) -> Value {
let what = format!("{tool} {}", truncated(&args));
let r = m
.try_rpc("tools/call", json!({"name": tool, "arguments": args}))
.unwrap_or_else(|e| panic!("{what}: {e}"));
assert!(
r.get("error").is_none(),
"{what} never reached the tool: {r}"
);
check_answer(m, &what, Ok(r.clone())).unwrap();
r["result"].clone()
}
/// The calls that once stopped the server, each pinned (P0 review round 3,
/// S1, and the audit after it): each is answered, and the server serves on.
#[test]
fn inputs_that_once_panicked_are_refused_or_answered() {
let p = places("known");
let mut m = Mcp::start(&p);
fixture(&mut m, &p);
const MAX: &str = "79228162514264337593543950335";
let big = "900000000000000.00";
let acme = party_id(&mut m, "Acme");
let client = party_id(&mut m, "Client Co");
let lines = |a: &str, b: &str| {
json!([{"account": "6999", "side": "debit", "amount": a},
{"account": "6999", "side": "debit", "amount": b},
{"account": "1000", "side": "credit", "amount": "1"}])
};
let payroll = |fed: &str, ss: &str| {
json!([{"employee": "Dana", "gross_pay": "1", "federal_withholding": fed,
"state_withholding": fed, "ss_employee": "0", "medicare_employee": "0",
"ss_employer": ss, "medicare_employer": "1", "other_deductions": "0"}])
};
let schedule = |start: &str, months: Value| {
json!({"kind": "prepaid_expense", "description": "x", "total": "100.00",
"start": start, "months": months, "balance_account": "1300",
"pl_account": "6300"})
};
let asset = |date: &str, months: Value| {
json!({"description": "x", "purchase_date": date, "cost": "100.00",
"useful_life_months": months, "cost_account": "1500",
"accum_depr_account": "1501", "expense_account": "6100"})
};
let calls: Vec<(&str, Value)> = vec![
("books.run_depreciation", json!({"period": "999999-01"})),
(
"books.run_depreciation",
json!({"period": "999999-01", "asset_id": 1}),
),
("books.run_depreciation", json!({"period": "262142-12"})),
("books.run_depreciation", json!({"period": "9999-12"})),
("books.create_schedule", schedule("+262142-12-15", json!(1))),
(
"books.create_schedule",
schedule("+262142-06-01", json!(12)),
),
("books.create_schedule", schedule("9999-12-15", json!(2))),
(
"books.create_schedule",
schedule("2026-01-01", json!(u32::MAX)),
),
("books.list_schedules", json!({})),
("books.run_schedules", json!({"through": "9999-12-31"})),
("books.register_asset", asset("+262142-11-30", json!(1))),
("books.register_asset", asset("9999-11-30", json!(1))),
("books.register_asset", asset("2026-01-01", json!(u32::MAX))),
("books.asset_schedule", json!({"asset_id": 2})),
("books.asset_schedule", json!({"asset_id": 3})),
("books.list_assets", json!({})),
(
"books.register_asset",
json!({"description": "y", "purchase_date": "2026-01-01", "cost": "100.00",
"useful_life_months": 12, "cost_account": "1500",
"accum_depr_account": "1501", "expense_account": "6100",
"tax_basis_rule": "carryover", "contributor_basis": MAX}),
),
(
"books.set_asset_tax_basis",
json!({"asset": "1", "rule": "carryover", "contributor_basis": MAX}),
),
(
"books.export",
json!({"report": "fixed_asset_register", "format": "xlsx"}),
),
(
"books.create_invoice",
json!({"party_id": client, "date": "2026-05-01", "due_date": "2026-06-01",
"lines": [{"description": "a", "amount": MAX},
{"description": "b", "amount": "1"}]}),
),
(
"books.create_bill",
json!({"party_id": acme, "date": "2026-05-01", "due_date": "2026-06-01",
"lines": [{"description": "a", "amount": MAX},
{"description": "b", "amount": "1"}]}),
),
(
"books.create_invoice",
json!({"party_id": client, "date": "2026-05-01", "due_date": "+262142-12-31",
"lines": [{"description": "a", "amount": "1"}]}),
),
(
"books.record_payout",
json!({"date": "2026-05-01", "description": "x", "deposit": "1",
"deposit_account": "1000",
"revenue": [{"account": "4000", "amount": MAX},
{"account": "4000", "amount": "1"}]}),
),
(
"books.record_payout",
json!({"date": "2026-05-01", "description": "x", "deposit": format!("-{MAX}"),
"deposit_account": "1000",
"revenue": [{"account": "4000", "amount": big}],
"fees": [{"account": "6999", "amount": format!("-{MAX}")}]}),
),
(
"books.run_payroll",
json!({"date": "2026-05-01", "cash_account": "1000", "lines": payroll(MAX, "1")}),
),
(
"books.run_payroll",
json!({"date": "2026-05-01", "cash_account": "1000", "lines": payroll("0", MAX)}),
),
(
"books.create_recurring",
json!({"description": "x", "frequency": "monthly", "start": "2026-01-01",
"lines": lines(MAX, "1")}),
),
(
"books.dispose_asset",
json!({"asset_id": 1, "date": "2026-06-30", "proceeds": MAX,
"proceeds_account": "1000", "gain_loss_account": "4900"}),
),
(
"books.dispose_asset",
json!({"asset_id": 1, "date": "+262142-12-31", "proceeds": "1",
"proceeds_account": "1000", "gain_loss_account": "4900"}),
),
(
"books.reconcile",
json!({"account": "1000", "statement_date": "2026-06-30",
"statement_balance": format!("-{MAX}")}),
),
(
"books.record_statement",
json!({"attachment_id": 2, "account": "1000", "period_start": "2026-06-01",
"period_end": "2026-06-30", "opening_balance": "0",
"closing_balance": format!("-{MAX}")}),
),
(
"books.reconcile",
json!({"account": "1000", "statement_attachment": 2}),
),
(
"books.export",
json!({"report": "balance_sheet", "format": "xlsx", "as_of": "+262142-06-30"}),
),
(
"books.export",
json!({"report": "balance_sheet", "format": "pdf", "as_of": "-262143-01-01"}),
),
(
"books.export",
json!({"report": "balance_sheet", "format": "pdf", "as_of": "9999-12-31"}),
),
(
"books.export_cpa_package",
json!({"as_of": "+262142-06-30", "cash_accounts": ["1000"], "include_tins": false}),
),
(
"books.export_cpa_package",
json!({"as_of": "9999-12-31", "cash_accounts": ["1000"], "include_tins": false}),
),
(
"books.profit_and_loss",
json!({"start": "0001-01-01", "end": "9999-12-31"}),
),
(
"books.profit_and_loss",
json!({"start": "-262143-01-01", "end": "+262142-12-31"}),
),
(
"books.show",
json!({"report": "profit_and_loss", "start": "+10000-01-01", "end": "+10000-12-31"}),
),
(
"books.create_recurring",
json!({"description": "x", "frequency": "weekly", "start": "-262143-01-01",
"lines": lines("1", "1")}),
),
("books.run_recurring", json!({"through": "9999-12-31"})),
("books.vendor_1099", json!({"year": 2026})),
("books.vendor_1099", json!({"year": 262142})),
("books.vendor_1099", json!({"year": i64::MIN})),
("books.digest", json!({"since": "+262142-12-31"})),
(
"books.create_period",
json!({"name": "far", "start": "+262142-12-01", "end": "+262142-12-31"}),
),
(
"books.create_period",
json!({"name": "far", "start": "9999-12-01", "end": "9999-12-31"}),
),
];
for (tool, args) in calls {
pinned(&mut m, tool, args);
}
// Two vendors' shares of one payment at the line limit (the 1099
// worksheet multiplies a payment by a share), and members' capital at
// it (the K-1 worksheet multiplies too).
let id = m.ok(
"books.draft_entry",
json!({"date": "2026-05-02", "description": "huge but legal"}),
)["entry_id"]
.clone();
for (account, side, amount) in [
("6000", "debit", big),
("1000", "credit", "450000000000000.00"),
("2050", "credit", "450000000000000.00"),
] {
pinned(
&mut m,
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
pinned(
&mut m,
"books.set_entry_party",
json!({"entry_id": id, "parties": [{"party": "Acme", "amount": "450000000000000.00"},
{"party": "Dana", "amount": "450000000000000.00"}]}),
);
pinned(
&mut m,
"books.set_entry_vendor",
json!({"entry_id": id, "vendor": "Acme"}),
);
pinned(
&mut m,
"books.attach_receipt",
json!({"entry_id": id, "file_name": "h.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQgaHVnZQ=="}),
);
pinned(
&mut m,
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
for (tool, args) in [
("books.vendor_1099", json!({"year": 2026})),
(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
),
("books.trial_balance", json!({})),
("books.cash_flow", json!({"cash_accounts": ["1000"]})),
] {
pinned(&mut m, tool, args);
}
// A draft holding a line the books can't sum, read every way.
let id = m.ok(
"books.draft_entry",
json!({"date": "2026-05-01", "description": "huge", "party": "Acme"}),
)["entry_id"]
.clone();
for amount in [MAX, "1"] {
pinned(
&mut m,
"books.add_line",
json!({"entry_id": id, "account": "6999", "side": "debit", "amount": amount}),
);
}
for (tool, args) in [
("books.get_entry", json!({"entry_id": id})),
("books.party", json!({"party": "Acme"})),
(
"books.set_entry_party",
json!({"entry_id": id, "parties": [{"party": "Acme", "amount": MAX},
{"party": "Dana", "amount": "1"}]}),
),
("books.post_entry", json!({"entry_id": id})),
("books.search_entries", json!({"text": "huge"})),
("books.digest", json!({})),
] {
pinned(&mut m, tool, args);
}
// And after a restart, the books open and serve.
drop(m);
let mut m = Mcp::start(&p);
for (tool, args) in [
("books.chart", json!({})),
("books.list_schedules", json!({})),
("books.list_assets", json!({})),
("books.run_schedules", json!({"through": "2026-12-31"})),
] {
pinned(&mut m, tool, args);
}
let _ = std::fs::remove_dir_all(&p.base);
}
/// INVARIANT 3: resources/read with generated URIs.
#[test]
fn hostile_resource_uris_never_stop_the_server() {
let p = places("resources");
let mut m = Mcp::start(&p);
fixture(&mut m, &p);
let bases = [
"books://chart",
"books://reports/trial-balance",
"books://reports/balance-sheet",
"books://reports/profit-and-loss",
"books://digest",
"books://digest?since=",
"books://digest?since=+262142-12-31",
"books://digest?since=0000-00-00",
"books://digest?since=9999-12-31",
"books://digest?since=2026-02-30&since=1",
"books://handbook",
"books://handbook/",
"books://handbook/contract",
"books://handbook/all",
"books://handbook/../contract",
"books://receipts/",
"books://receipts/1",
"books://receipts/0",
"books://receipts/18446744073709551615",
"books://receipts/18446744073709551616",
"books://receipts/-1",
"books://nope",
"",
"books:",
"file:///etc/passwd",
"books://chart?x=1",
];
let uri = prop_oneof![
proptest::sample::select(bases.to_vec()).prop_map(str::to_string),
(proptest::sample::select(bases.to_vec()), "\\PC{0,24}")
.prop_map(|(b, s)| format!("{b}{s}")),
"\\PC{0,64}",
"books://[a-z/?=&.%-]{0,40}",
];
// No shrinking: the server is shared, so once one call stops it
// every smaller case fails too; the case reported is the first.
let mut runner = TestRunner::new(Config {
cases: cases(),
failure_persistence: None,
max_shrink_iters: 0,
..Config::default()
});
let m = std::cell::RefCell::new(m);
runner
.run(&uri, |uri| {
let mut m = m.borrow_mut();
let r = m.try_rpc("resources/read", json!({"uri": uri}));
check_answer(&mut m, &format!("resources/read {uri:?}"), r)
})
.unwrap();
reopens(m.into_inner(), &p, "the resources");
let _ = std::fs::remove_dir_all(&p.base);
}
fn b64(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
/// The arguments, cut for a failure message.
fn truncated(v: &Value) -> String {
let s = v.to_string();
if s.chars().count() > 600 {
format!("{}…", s.chars().take(600).collect::<String>())
} else {
s
}
}
/// Every file of one fixture's books (its journal and blobs; never the
/// expected figures beside them) copied into `to`.
fn copy_books(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for e in std::fs::read_dir(from).unwrap() {
let e = e.unwrap();
let name = e.file_name();
if name == "expected.json" {
continue;
}
if e.file_type().unwrap().is_dir() {
copy_books(&e.path(), &to.join(&name));
} else {
std::fs::copy(e.path(), to.join(&name)).unwrap();
}
}
}
/// The books an earlier wanderbooks wrote (ledger-store's
/// tests/fixtures/old-books, made with main 188c90ea's Store API: dates
/// past 9999 and before 0001, lines and shares past the line limit,
/// sub-cent document and payroll lines, and the edges of chrono's calendar
/// and of Decimal), in the order the directory lists them.
fn old_books() -> Vec<PathBuf> {
let root =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ledger-store/tests/fixtures/old-books");
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.is_dir())
.collect();
dirs.sort();
assert!(dirs.len() >= 14, "{} old books", dirs.len());
dirs
}
/// Calls that read old books the ways a human or an agent does: every
/// report over the widest range a command takes, every record by id.
fn reads_of_old_books() -> Vec<(&'static str, Value)> {
let wide = json!({"start": "0001-01-01", "end": "9999-12-31"});
let mut calls = vec![
("books.chart", json!({})),
("books.verify", json!({})),
("books.digest", json!({})),
("books.digest", json!({"since": "0001-01-01"})),
("books.settings", json!({})),
("books.roles", json!({})),
("books.trial_balance", json!({})),
("books.trial_balance", json!({"as_of": "9999-12-31"})),
("books.balance_sheet", json!({})),
("books.balance_sheet", json!({"as_of": "9999-12-31"})),
("books.balance_sheet", json!({"as_of": "0001-01-01"})),
("books.profit_and_loss", wide.clone()),
("books.profit_and_loss", json!({})),
("books.profit_and_loss_by_class", wide.clone()),
("books.cash_flow", wide.clone()),
("books.cash_flow", json!({"cash_accounts": ["1000"]})),
("books.ar_aging", json!({})),
("books.ar_aging", json!({"as_of": "9999-12-31"})),
("books.ap_aging", json!({"as_of": "0001-01-01"})),
("books.list_assets", json!({})),
("books.list_schedules", json!({})),
("books.list_recurring", json!({})),
("books.list_periods", json!({})),
("books.list_payroll_runs", json!({})),
("books.list_receipts", json!({})),
("books.list_vendors", json!({})),
("books.list_classes", json!({})),
("books.parties", json!({})),
("books.statements", json!({})),
("books.reconciliations", json!({})),
("books.attachments", json!({})),
("books.vendor_1099", json!({"year": 2026})),
("books.vendor_1099", json!({"year": 9999})),
("books.search_entries", json!({"text": "e"})),
("books.asset_labels", json!({})),
("books.run_schedules", json!({"through": "9999-12-31"})),
("books.run_recurring", json!({"through": "2026-12-31"})),
("books.run_depreciation", json!({"period": "2026-03"})),
("books.run_depreciation", json!({"period": "9999-12"})),
(
"books.reconcile",
json!({"account": "1000", "statement_date": "2026-03-31",
"statement_balance": "10.00"}),
),
(
"books.reconcile",
json!({"account": "1000", "statement_attachment": 1}),
),
(
"books.export_cpa_package",
json!({"as_of": "9999-12-31", "cash_accounts": ["1000"], "include_tins": false}),
),
(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
),
];
for id in 1..=3 {
calls.push(("books.get_entry", json!({"entry_id": id})));
calls.push(("books.asset_schedule", json!({"asset_id": id})));
calls.push(("books.post_entry", json!({"entry_id": id})));
calls.push(("books.party", json!({"party": id})));
calls.push((
"books.close_period",
json!({"period_id": id, "confirmed": false}),
));
calls.push((
"books.dispose_asset",
json!({"asset_id": id, "date": "9999-12-31", "proceeds": "0.00",
"proceeds_account": "1000", "gain_loss_account": "6000"}),
));
}
for report in [
"trial_balance",
"balance_sheet",
"profit_and_loss",
"cash_flow",
"general_ledger",
"fixed_asset_register",
] {
for format in ["csv", "xlsx", "pdf", "json"] {
calls.push(("books.export", json!({"report": report, "format": format})));
calls.push((
"books.export",
json!({"report": report, "format": format, "as_of": "9999-12-31",
"start": "0001-01-01", "end": "9999-12-31"}),
));
}
}
for (report, interval) in [
("profit_and_loss", "month"),
("profit_and_loss", "year"),
("balance_sheet", "quarter"),
("cash_flow", "year"),
] {
calls.push((
"books.show",
json!({"report": report, "start": "0001-01-01", "end": "9999-12-31",
"interval": interval}),
));
}
calls
}
/// Books an earlier version wrote open, and every tool answers over them
/// (rules.md B-28: posted is forever; a figure the books can't compute is
/// refused, never a panic). Against each fixture: the reads above, then
/// OLD_BOOKS_CASES (default 3) generated calls of every tool; after them
/// the server restarts and must open the books again (a call a command
/// accepts but replay refuses would leave books that never reopen).
#[test]
fn old_books_answer_every_tool() {
let per_tool: usize = std::env::var("OLD_BOOKS_CASES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3);
// Each book on its own server, several at once.
let books = old_books();
let workers = std::thread::available_parallelism().map_or(4, |n| n.get().min(8));
let next = std::sync::atomic::AtomicUsize::new(0);
let failures: Vec<String> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..workers)
.map(|_| {
scope.spawn(|| {
let mut failures = Vec::new();
loop {
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let Some(dir) = books.get(i) else { break };
failures.extend(old_book_answers_every_tool(dir, per_tool));
}
failures
})
})
.collect();
handles
.into_iter()
.flat_map(|h| h.join().expect("a worker panicked"))
.collect()
});
assert!(
failures.is_empty(),
"{} failures:\n{}",
failures.len(),
failures.join("\n")
);
}
/// INVARIANT 5 for one old book: the failures, if any.
fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec<String> {
let mut failures: Vec<String> = Vec::new();
let name = dir.file_name().unwrap().to_string_lossy().to_string();
let p = places(&format!("old-{name}"));
copy_books(dir, &p.books.join("books-data"));
let mut m = Mcp::start(&p);
let list = m.rpc("tools/list", json!({}));
let tools: Vec<(String, Value)> = list["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| {
(
t["name"].as_str().unwrap().to_string(),
t["inputSchema"].clone(),
)
})
.collect();
let mut calls: Vec<(String, Value)> = reads_of_old_books()
.into_iter()
.map(|(t, a)| (t.to_string(), a))
.collect();
let mut runner = TestRunner::deterministic();
for (tool, schema) in &tools {
for _ in 0..per_tool {
let args = args_for(schema, &p)
.new_tree(&mut runner)
.unwrap()
.current();
calls.push((tool.clone(), args));
}
}
for (tool, args) in calls {
let what = format!("{name}: {tool} {}", truncated(&args));
let r = m.try_rpc("tools/call", json!({"name": tool, "arguments": args}));
if let Err(e) = check_answer(&mut m, &what, r) {
failures.push(e.to_string());
drop(m);
m = Mcp::start(&p);
}
}
for uri in [
"books://chart",
"books://reports/trial-balance",
"books://reports/balance-sheet",
"books://reports/profit-and-loss",
"books://digest",
] {
let r = m.try_rpc("resources/read", json!({"uri": uri}));
if let Err(e) = check_answer(&mut m, &format!("{name}: {uri}"), r) {
failures.push(e.to_string());
drop(m);
m = Mcp::start(&p);
}
}
// Whatever the calls wrote, the books reopen.
drop(m);
let mut m = Mcp::start(&p);
let r = m.try_rpc(
"tools/call",
json!({"name": "books.chart", "arguments": {}}),
);
if let Err(e) = check_answer(&mut m, &format!("{name}: reopened"), r) {
failures.push(e.to_string());
}
drop(m);
let _ = std::fs::remove_dir_all(&p.base);
failures
}