Files
wanderbooks/crates/ledger-mcp/tests/mcp_stdio.rs
T

444 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! End-to-end smoke test: drive the real `ledger-mcp` binary over stdio
//! with raw JSON-RPC, exercising draft → preview → receipt policy → post.
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
struct Mcp {
stdin: ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: u64,
#[allow(dead_code)]
child: Child,
}
impl Mcp {
fn spawn(data_dir: &std::path::Path) -> Self {
let bin = env!("CARGO_BIN_EXE_ledger-mcp");
let mut child = Command::new(bin)
.arg("--data-dir")
.arg(data_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
Self {
stdin,
stdout,
next_id: 1,
child,
}
}
fn request(&mut self, method: &str, params: Value) -> Value {
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}").unwrap();
self.stdin.flush().unwrap();
let mut line = String::new();
self.stdout
.read_line(&mut line)
.expect("read response line");
let resp: Value = serde_json::from_str(&line).expect("parse response");
assert_eq!(resp["id"], id, "id mismatch in {resp}");
assert!(
resp.get("error").is_none(),
"JSON-RPC error for {method}: {}",
resp["error"]
);
resp
}
fn notify(&mut self, method: &str) {
let msg = json!({"jsonrpc": "2.0", "method": method, "params": {}});
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
}
/// Call a tool; panic on tool error. Returns structuredContent.
fn call(&mut self, tool: &str, args: Value) -> Value {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
let result = &resp["result"];
assert_ne!(
result.get("isError"),
Some(&json!(true)),
"tool {tool} returned an error: {}",
result["content"]
);
result.get("structuredContent").cloned().unwrap_or_else(|| {
let text = result["content"][0]["text"].as_str().unwrap();
serde_json::from_str(text).unwrap()
})
}
/// Call a tool expecting an error; returns the error text.
fn call_err(&mut self, tool: &str, args: Value) -> String {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
let result = &resp["result"];
assert_eq!(
result.get("isError"),
Some(&json!(true)),
"expected {tool} to fail, got {result}"
);
result["content"][0]["text"].as_str().unwrap().to_string()
}
}
fn test_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ledger-mcp-test-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn mcp_end_to_end_over_stdio() {
let dir = test_dir("main");
let mut mcp = Mcp::spawn(&dir);
// Initialize: the server identifies itself and delivers the contract.
let init = mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
let instructions = init["result"]["instructions"].as_str().unwrap();
assert!(instructions.contains("bookkeeping agent"));
assert!(instructions.contains("decimal strings"));
mcp.notify("notifications/initialized");
// Tool listing: 23 narrow tools, chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 23, "expected 23 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
chart["description"]
.as_str()
.unwrap()
.contains("chart of accounts")
);
// Fresh books: empty chart.
let chart_out = mcp.call("books.chart", json!({}));
assert_eq!(chart_out["accounts"].as_array().unwrap().len(), 0);
assert_eq!(chart_out["base_currency"], "USD");
// Build a chart.
for (code, name, typ) in [
("1000", "Cash", "asset"),
("3000", "Owner's Equity", "equity"),
("4000", "Service Revenue", "revenue"),
("6000", "Rent Expense", "expense"),
] {
let out = mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
assert!(out["account_id"].as_u64().unwrap() > 0);
}
// Duplicate code is rejected.
let err = mcp.call_err(
"books.add_account",
json!({"code": "1000", "name": "Cash 2", "account_type": "asset"}),
);
assert!(err.contains("already in use"), "got: {err}");
// Draft → lines → preview.
let draft = mcp.call(
"books.draft_entry",
json!({"date": "2026-01-15", "description": "January rent"}),
);
let entry_id = draft["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": entry_id, "account": "6000", "side": "debit", "amount": "100.00"}),
);
mcp.call(
"books.add_line",
json!({"entry_id": entry_id, "account": "1000", "side": "credit", "amount": "100.00"}),
);
let preview = mcp.call("books.post_entry", json!({"entry_id": entry_id}));
assert_eq!(preview["status"], "preview");
assert_eq!(preview["total"], "100.00");
assert_eq!(preview["receipt_required"], true);
assert_eq!(preview["receipt_missing"], true);
assert_eq!(preview["lines"].as_array().unwrap().len(), 2);
// Resulting balances: rent expense goes 0 → 100.
let rent_line = preview["lines"]
.as_array()
.unwrap()
.iter()
.find(|l| l["code"] == "6000")
.unwrap();
assert_eq!(rent_line["balance_before"], "0");
assert_eq!(rent_line["balance_after"], "100.00");
// Confirmed post without a receipt is rejected: >$25, no receipt.
let err = mcp.call_err(
"books.post_entry",
json!({"entry_id": entry_id, "confirmed": true}),
);
assert!(err.contains("receipt_required"), "got: {err}");
// Attach the receipt, then the confirmed post succeeds.
let png = base64_like(b"fake-png-bytes");
let attached = mcp.call(
"books.attach_receipt",
json!({
"entry_id": entry_id,
"file_name": "rent.png",
"mime_type": "image/png",
"content_base64": png,
}),
);
assert_eq!(attached["deduplicated"], false);
assert_eq!(attached["sha256"].as_str().unwrap().len(), 64);
let posted = mcp.call(
"books.post_entry",
json!({"entry_id": entry_id, "confirmed": true}),
);
assert_eq!(posted["status"], "posted");
assert_eq!(posted["receipt_missing"], false);
// Reports agree.
let tb = mcp.call("books.trial_balance", json!({}));
assert_eq!(tb["balanced"], true);
assert_eq!(tb["total_debits"], "100.00");
assert_eq!(tb["total_credits"], "100.00");
let pnl = mcp.call(
"books.profit_and_loss",
json!({"start": "2026-01-01", "end": "2026-01-31"}),
);
assert_eq!(pnl["expenses"], "100.00");
assert_eq!(pnl["net_income"], "-100.00");
let bs = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
assert_eq!(bs["balanced"], true);
// Digest and search see the entry.
let digest = mcp.call("books.digest", json!({"since": "2026-01-01"}));
assert_eq!(digest["entries"].as_array().unwrap().len(), 1);
assert_eq!(digest["entries"][0]["receipt_missing"], false);
let search = mcp.call("books.search_entries", json!({"text": "rent"}));
assert_eq!(search["entries"].as_array().unwrap().len(), 1);
assert_eq!(search["entries"][0]["receipt_count"], 1);
// Export works.
let export = mcp.call(
"books.export",
json!({"format": "csv", "report": "journal"}),
);
assert!(export["content"].as_str().unwrap().contains("January rent"));
// Reversal: preview then post; the book still balances.
let rev_preview = mcp.call(
"books.reverse_entry",
json!({"entry_id": entry_id, "date": "2026-01-16", "description": "Reverse rent"}),
);
assert_eq!(rev_preview["status"], "preview");
let rev = mcp.call(
"books.reverse_entry",
json!({"entry_id": entry_id, "date": "2026-01-16", "description": "Reverse rent", "confirmed": true}),
);
assert_eq!(rev["status"], "posted");
let tb2 = mcp.call("books.trial_balance", json!({}));
assert_eq!(tb2["balanced"], true);
assert_eq!(tb2["total_debits"], "0");
}
#[test]
fn mcp_assets_end_to_end_over_stdio() {
let dir = test_dir("assets");
let mut mcp = Mcp::spawn(&dir);
let init = mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
mcp.notify("notifications/initialized");
// Chart for the asset: cost, contra, expense, bank, gain.
for (code, name, typ) in [
("1500", "Equipment", "asset"),
("1501", "Accumulated Depreciation", "asset"),
("6100", "Depreciation Expense", "expense"),
("1000", "Bank", "asset"),
("4900", "Gain on Disposal", "revenue"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
// Register the van: 60,000, 60-month life, bought 2026-01-15.
let reg = mcp.call(
"books.register_asset",
json!({
"description": "Sprinter van",
"purchase_date": "2026-01-15",
"cost": "60000.00",
"useful_life_months": 60,
"cost_account": "1500",
"accum_depr_account": "1501",
"expense_account": "6100",
}),
);
let asset_id = reg["asset_id"].as_u64().unwrap();
// Schedule: 61 calendar months, lifetime total exactly 60,000.
let sched = mcp.call("books.asset_schedule", json!({"asset_id": asset_id}));
let lines = sched["schedule"].as_array().unwrap();
assert_eq!(lines.len(), 61);
assert_eq!(lines[0]["period"], "2026-01");
assert_eq!(lines[0]["amount"], "548.39");
assert_eq!(lines[60]["period"], "2031-01");
let total: f64 = lines
.iter()
.map(|l| l["amount"].as_str().unwrap().parse::<f64>().unwrap())
.sum();
assert!((total - 60000.0).abs() < 0.005, "total was {total}");
assert_eq!(sched["status"], "active");
// List: one active asset.
let list = mcp.call("books.list_assets", json!({}));
assert_eq!(list["assets"].as_array().unwrap().len(), 1);
assert_eq!(list["assets"][0]["status"], "active");
// Run January: posts 548.39.
let run1 = mcp.call(
"books.run_depreciation",
json!({"period": "2026-01", "asset_id": asset_id}),
);
assert_eq!(run1["runs"][0]["amount"], "548.39");
assert_eq!(run1["runs"][0]["already_posted"], false);
let entry_id = run1["runs"][0]["entry_id"].as_u64().unwrap();
// Idempotent: second run returns the same entry.
let run2 = mcp.call(
"books.run_depreciation",
json!({"period": "2026-01", "asset_id": asset_id}),
);
assert_eq!(run2["runs"][0]["already_posted"], true);
assert_eq!(run2["runs"][0]["entry_id"], entry_id);
// Run-all for February: picks up the van, posts a full 1,000.
let run_all = mcp.call("books.run_depreciation", json!({"period": "2026-02"}));
assert_eq!(run_all["runs"][0]["amount"], "1000.00");
assert_eq!(run_all["skipped"].as_array().unwrap().len(), 0);
// And again: already posted.
let run_all2 = mcp.call("books.run_depreciation", json!({"period": "2026-02"}));
assert_eq!(run_all2["runs"][0]["already_posted"], true);
// Dispose preview: book value 60000 − 1548.39 = 58451.61;
// proceeds 60000 → gain 1548.39. Receipt required.
let preview = mcp.call(
"books.dispose_asset",
json!({
"asset_id": asset_id,
"date": "2026-02-28",
"proceeds": "60000",
"proceeds_account": "1000",
"gain_loss_account": "4900",
}),
);
assert_eq!(preview["status"], "preview");
assert_eq!(preview["book_value"], "58451.61");
assert_eq!(preview["gain_loss"], "1548.39");
assert_eq!(preview["final_depreciation"], "0");
assert_eq!(preview["receipt_required"], true);
// Confirmed without the bill of sale: rejected.
let err = mcp.call_err(
"books.dispose_asset",
json!({
"asset_id": asset_id,
"date": "2026-02-28",
"proceeds": "60000",
"proceeds_account": "1000",
"gain_loss_account": "4900",
"confirmed": true,
}),
);
assert!(err.contains("receipt_required"), "got: {err}");
// File the bill of sale, then confirm with its attachment id.
let bill = mcp.call(
"books.file_document",
json!({
"category": "legal",
"file_name": "bill-of-sale.pdf",
"mime_type": "application/pdf",
"content_base64": base64_like(b"fake bill of sale"),
}),
);
let aid = bill["attachment_id"].as_u64().unwrap();
let posted = mcp.call(
"books.dispose_asset",
json!({
"asset_id": asset_id,
"date": "2026-02-28",
"proceeds": "60000",
"proceeds_account": "1000",
"gain_loss_account": "4900",
"confirmed": true,
"receipt_attachment_ids": [aid],
}),
);
assert_eq!(posted["status"], "posted");
assert_eq!(posted["gain_loss"], "1548.39");
// The bill of sale is linked to the disposal entry.
let receipts = mcp.call(
"books.list_receipts",
json!({"entry_id": posted["entry_id"]}),
);
assert_eq!(receipts["receipts"].as_array().unwrap().len(), 1);
// Asset is disposed; further depreciation is refused.
let list2 = mcp.call("books.list_assets", json!({}));
assert_eq!(list2["assets"][0]["status"], "disposed");
let err2 = mcp.call_err(
"books.run_depreciation",
json!({"period": "2026-03", "asset_id": asset_id}),
);
assert!(err2.contains("disposed"), "got: {err2}");
// The books still balance.
let tb = mcp.call("books.trial_balance", json!({}));
assert_eq!(tb["balanced"], true);
}
fn base64_like(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let mut n: u32 = 0;
for (i, &b) in chunk.iter().enumerate() {
n |= (b as u32) << (16 - 8 * i);
}
let pad = 3 - chunk.len();
for i in 0..4 - pad {
out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char);
}
for _ in 0..pad {
out.push('=');
}
}
out
}