Files
wanderbooks/crates/ledger-mcp/src/main.rs
T
swaitsandClaude Opus 5.5 9694d2a89a feat: end-to-end integrity checks with self-healing; website; installer and Windows fixes
Store::verify compares the whole SQLite projection, every table row for
row, against a fresh projection of the journal; verify_and_heal rebuilds a
drifted database (keeping the old file) at every server start and via the
new books.verify tool, which also re-hashes every blob. The GUI checks the
server's database read-only and every stored file, and shows the result.
rebuild() also drops stale WAL/SHM files.

site/: the wanderbooks website (just site, just publish). install.sh:
OS detection (Windows points to WSL2), C compiler and git checks,
WANDERBOOKS_REPO. Windows: no directory fsync off Unix; .exe-aware lookup.
Repo justfile: check, build, install, demo, site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:05:05 -07:00

183 lines
6.8 KiB
Rust

//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio.
//!
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
//!
//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
//! and optionally `config.json`. If no store exists, one is created with the
//! configured base currency. Company settings (name, fiscal year start,
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
//! default the one next to the data dir.
use ledger_config as company;
mod policy;
mod server;
mod types;
use ledger_core::Currency;
use ledger_store::Store;
use policy::ReceiptPolicy;
use rmcp::{ServiceExt, transport::stdio};
use serde::Deserialize;
use server::BooksServer;
use std::path::PathBuf;
/// Server configuration, JSON. All fields optional.
#[derive(Debug, Default, Deserialize)]
struct Config {
/// ISO 4217 base currency for a new store. Default "USD".
#[serde(default)]
base_currency: Option<String>,
/// Receipt policy. Overrides `wanderbooks.toml`'s receipt_threshold;
/// with neither, receipts are required above $25.00.
#[serde(default)]
receipt_policy: Option<ReceiptPolicy>,
}
fn default_data_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".local/share/wanderbooks")
}
fn print_usage() {
eprintln!(
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--http ADDR]"
);
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
eprintln!(
" --books-toml company settings (default <data-dir>/../wanderbooks.toml, optional)"
);
eprintln!(
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut data_dir = default_data_dir();
let mut config_path: Option<PathBuf> = None;
let mut books_toml: Option<PathBuf> = None;
let mut http_addr: Option<String> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--data-dir" => {
data_dir = PathBuf::from(args.next().ok_or("--data-dir needs a path")?);
}
"--config" => {
config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?));
}
"--books-toml" => {
books_toml = Some(PathBuf::from(
args.next().ok_or("--books-toml needs a path")?,
));
}
"--http" => {
http_addr = Some(args.next().ok_or("--http needs an address")?);
}
"--help" | "-h" => {
print_usage();
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
print_usage();
std::process::exit(2);
}
}
}
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
let config: Config = if config_path.exists() {
let raw = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&raw)
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?
} else {
Config::default()
};
// Logs go to stderr: stdout is the MCP channel.
eprintln!("ledger-mcp: data dir {}", data_dir.display());
// An explicit --books-toml must exist; the default is optional.
let settings = match books_toml {
Some(path) => company::load(&path)?,
None => match data_dir.parent().map(|p| p.join("wanderbooks.toml")) {
Some(path) if path.exists() => company::load(&path)?,
_ => company::BooksSettings::default(),
},
};
eprintln!("ledger-mcp: company {:?}", settings.company.name);
let policy = match (config.receipt_policy, settings.receipt_threshold) {
(Some(policy), _) => policy,
(None, Some(threshold)) => ReceiptPolicy {
require_receipts_above: threshold,
..ReceiptPolicy::default()
},
(None, None) => ReceiptPolicy::default(),
};
let company = settings.company;
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
Store::open(&data_dir)?
} else {
let code = config.base_currency.as_deref().unwrap_or("USD");
let currency = Currency::new(code).map_err(|_| format!("bad base currency {code:?}"))?;
eprintln!("ledger-mcp: creating new store (base currency {code})");
Store::create(&data_dir, currency)?
};
// The journal is the source of truth: verify the chain, the replay, and
// the whole projection before serving a single tool call, rebuilding
// the projection if it drifted.
let report = store
.verify_and_heal()
.map_err(|e| format!("store verify failed: {e}"))?;
if let Some(why) = &report.healed {
eprintln!("ledger-mcp: rebuilt the database from the journal ({why})");
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
if let Some(addr) = http_addr {
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}");
rt.block_on(serve_http(addr, store, policy, company))?;
return Ok(());
}
eprintln!("ledger-mcp: store verified, serving over stdio");
let server = BooksServer::new(store, policy).with_company(company);
rt.block_on(async {
let running = server.serve(stdio()).await?;
let _ = running.waiting().await;
Ok::<(), Box<dyn std::error::Error>>(())
})
}
/// Serve MCP over Streamable HTTP (for network clients).
async fn serve_http(
addr: String,
store: Store,
policy: ReceiptPolicy,
company: company::Company,
) -> Result<(), Box<dyn std::error::Error>> {
use rmcp::transport::streamable_http_server::{
StreamableHttpService, session::local::LocalSessionManager,
};
use std::sync::Arc;
let store = Arc::new(std::sync::Mutex::new(store));
let service = StreamableHttpService::new(
move || {
let server = server::BooksServer::new_shared(store.clone(), policy.clone())
.with_company(company.clone());
Ok(server)
},
Arc::new(LocalSessionManager::default()),
Default::default(),
);
let router = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind(&addr).await?;
eprintln!("ledger-mcp: listening on http://{addr}/mcp");
axum::serve(listener, router).await?;
Ok(())
}