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
183 lines
6.8 KiB
Rust
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(())
|
|
}
|