From 08b53d2e5518ccecc272702fe451772872d2fea2 Mon Sep 17 00:00:00 2001 From: Stephen Waits <1+swaits@noreply.git.swaits.com> Date: Sun, 11 Oct 2026 01:37:24 +0000 Subject: [PATCH] First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) --- crates/ledger-mcp/src/main.rs | 102 ++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 crates/ledger-mcp/src/main.rs diff --git a/crates/ledger-mcp/src/main.rs b/crates/ledger-mcp/src/main.rs new file mode 100644 index 0000000..60e5055 --- /dev/null +++ b/crates/ledger-mcp/src/main.rs @@ -0,0 +1,102 @@ +//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio. +//! +//! Usage: `ledger-mcp [--data-dir PATH] [--config 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. + +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, + /// Receipt policy. Defaults to require-receipts-above $25.00. + #[serde(default)] + receipt_policy: 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]"); + eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)"); + eprintln!(" --config JSON config file (default /config.json, optional)"); +} + +fn main() -> Result<(), Box> { + let mut data_dir = default_data_dir(); + let mut config_path: Option = 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")?)); + } + "--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()); + + let 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 and the + // projection before serving a single tool call. + store + .verify() + .map_err(|e| format!("store verify failed: {e}"))?; + eprintln!("ledger-mcp: store verified, serving over stdio"); + + let server = BooksServer::new(store, config.receipt_policy); + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + rt.block_on(async { + let running = server.serve(stdio()).await?; + let _ = running.waiting().await; + Ok::<(), Box>(()) + }) +}