refactor!: one way to serve books: the stdio server is gone (D-44)
check / check (push) Canceled after 0s

The owner (2026-10-23): "We should probably just delete the stdio path
altogether. THere's no point in that. And it sitting there unused will
just lead to bitrot. Nuke it?"

Books are served only by the books server (`wanderbooks server`),
reached by agents through the bridge (`wanderbooks mcp --book ID`),
which stays.

Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary, and
`ledger_mcp::run` (stdio and `--http` over a bare data dir), with all
that only that mode had:
- files the books make coming back inline: `HandedFile.content_base64`
  is gone, and `download` and `expires_at` are always there;
- books with no access log or backups: `books.access_log`,
  `books.backup` and `books.back_up_now` are always offered, by scope;
- the settings file read and reloaded (`legacy_files`), the vault key
  named by a file or `--vault-key-file`, and the books dir's
  key-inside-the-books and git checks (with `paths.rs`);
- `wire.rs` (the bridge frames an agent's lines itself);
- `books.settings`' file fields and `books.start`'s `settings_source`.

`BooksServer` is built one way, from `Served` (a book as the books
server serves it), in production and in the unit tests. An unknown tool
is now a protocol error (mcp-tools.md). Inline file input is unchanged.

Then the owner's three decisions (2026-10-23, the question tool):
(a) `books.import_settings` is removed entirely (118 tools): old books
come in whole by `wanderbooks books adopt`, which reads their settings
file once; ledger-config keeps its settings-file parsing only as far as
adoption uses it (`load` and the path-reading `effective_receipt_policy`
are gone), and `books.chart_template` gives its settings as
`books.set_setting` changes. (b) The viewer's loose-books-folder mode is
removed (`--data-dir`, `--books-toml`, `--vault-key-file`, the settings
file and config.json followed): it opens only books of the data root,
reading settings from the journal and the vault from the book's own
`vault/` and `keys/`. (c) `wanderbooks dev-stdio` has no stub: it is an
unknown command like any other word. Also: an upload's expiry past the
calendar is an error, never an empty string.

Tests: every suite that drove the stdio server moved to
crates/ledger-cli/tests/ and drives a real `wanderbooks server` on a
data root of its own (tests/common), handed files by their download;
mcp_stdio.rs is mcp_journeys.rs. Tests of what is gone are deleted and
listed in docs/testing.md. Docs: plan D-44 (D-18 superseded), STATUS,
testing, rules, mcp-interface, the handbook, README; justfile recipes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-23 19:45:18 -06:00
1 parent 0df7f52ac3
commit 7741e96eb3
87 files changed
+3477 -6476

No files matched your search

+11 -265
View File
@@ -1,19 +1,9 @@
//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio.
//!
//! [`run`] is the whole server; the `ledger-mcp` binary and `wanderbooks
//! dev-stdio` (one executable, plan D-22) both call it.
//!
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
//!
//! The data dir holds `journal.jsonl` (the source of truth),
//! `wanderbooks.v{N}.db` (the disposable SQLite projection, a file for each
//! schema: ledger_store::db_file_for), `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, and reload whenever it changes
//! (settings.rs). Files come in only as tool input (inline, at most 256
//! KiB): uploads need the books server's socket (`wanderbooks mcp`).
//! `ledger-mcp`: the AI-first bookkeeping interface. It is served one way
//! (plan D-44): by the books server, `wanderbooks server` ([`local`]),
//! every book of the data root on the root's local sockets, which agents
//! reach through the bridge `wanderbooks mcp --book ID`. Files come in as
//! tool input (inline, at most 256 KiB) or as uploads, and the files the
//! books make go out as downloads.
pub mod access;
pub mod admin;
@@ -22,59 +12,20 @@ mod handbook;
#[cfg(unix)]
pub mod local;
pub mod opening;
mod paths;
mod policy;
pub mod scopes;
mod server;
mod settings;
mod tin_files;
mod types;
mod wire;
use ledger_core::Currency;
use ledger_store::Store;
use policy::ReceiptPolicy;
use rmcp::ServiceExt;
use serde::Deserialize;
use server::BooksServer;
pub use server::{NOT_RUN_CLOSING, NOT_RUN_CLOSING_CODE};
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` is read by ledger_config::effective_receipt_policy.
}
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(prog: &str) {
eprintln!(
"usage: {prog} [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file 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, for books that don't keep their own yet (default <data-dir>/../wanderbooks.toml, optional)"
);
eprintln!(
" --vault-key-file the TIN vault's key file (default ~/.config/wanderbooks/vault.key; wins over a settings file's [vault] key_file)"
);
eprintln!(
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
);
}
/// How [`run`] stops when it can't go on: the reason is already printed
/// on stderr, and the number is the process's exit code (2: a bad argument; 1: books from a newer wanderbooks). A
/// library never ends the process itself, so the books it opened are
/// closed properly on the way out; the binaries turn this into the exit.
/// How a mode of the one executable stops when it can't go on: the
/// reason is already printed on stderr, and the number is the process's
/// exit code (2: a bad argument). A library never ends the process
/// itself, so the books it opened are closed properly on the way out;
/// the executable turns this into the exit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Exit(pub i32);
@@ -86,211 +37,6 @@ impl std::fmt::Display for Exit {
impl std::error::Error for Exit {}
/// Run the books server with `args` (the command line after the program
/// name). `prog` names the program in the usage text: `ledger-mcp`, or
/// `wanderbooks dev-stdio`. It returns when the client disconnects; a bad
/// argument or books from a newer wanderbooks return [`Exit`] after a
/// plain message.
pub fn run(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> 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 vault_key_file: Option<PathBuf> = None;
let mut http_addr: Option<String> = None;
let mut args = args.into_iter();
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")?,
));
}
"--vault-key-file" => {
vault_key_file = Some(PathBuf::from(
args.next().ok_or("--vault-key-file needs a path")?,
));
}
"--http" => {
http_addr = Some(args.next().ok_or("--http needs an address")?);
}
"--help" | "-h" => {
print_usage(prog);
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
print_usage(prog);
return Err(Box::new(Exit(2)));
}
}
}
// Books in the data root are the books server's alone (plan 3.1):
// a second writer beside it is refused before anything is opened.
if ledger_store::root::inside_a_root(&data_dir) {
eprintln!(
"ledger-mcp: those books are in the books' private folder, which only the books server opens: connect to them with `wanderbooks mcp --book <id>`"
);
return Err(Box::new(Exit(2)));
}
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
// config.json's base currency matters only to books being created: it
// is read then, and never otherwise (books that keep their own
// settings read no file).
let read_config = || -> Result<Config, Box<dyn std::error::Error>> {
if config_path.exists() {
let raw = std::fs::read_to_string(&config_path)?;
Ok(serde_json::from_str(&raw)
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?)
} else {
Ok(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 (beside the data
// dir) is optional. Either is reloaded whenever it changes.
let source = settings::Source {
required: books_toml.is_some(),
books_toml: books_toml.unwrap_or_else(|| {
data_dir
.parent()
.map(|p| p.join("wanderbooks.toml"))
.unwrap_or_else(|| PathBuf::from("wanderbooks.toml"))
}),
config_json: config_path.clone(),
};
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
match open_waiting_for_lock(&data_dir) {
Ok(store) => store,
// Books from a newer wanderbooks are fine; this server is old.
// Say so in plain words (the error's text), and leave them be.
Err(e @ ledger_store::StoreError::NewerJournal { .. }) => {
eprintln!("ledger-mcp: {e}");
return Err(Box::new(Exit(1)));
}
Err(e) => return Err(e.into()),
}
} else {
let config = read_config()?;
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})");
}
// The viewer's side channel, made as the books open (ledger_store::ops
// INVARIANT 2), so a viewer already open shows its presence before
// the first books.show. Not the books: a failure only says so.
if let Err(e) = ledger_store::ops::prepare(&data_dir) {
eprintln!("ledger-mcp: the viewer's side channel couldn't be made ({e})");
}
// Books that keep their own settings never read a settings file, not
// even to start (settings INVARIANT 1); others read it as before.
let mut live = if store.ledger().settings().is_journaled() {
settings::Live::journaled(source)
} else {
settings::Live::watching(source)?
}
.with_vault_key_file(vault_key_file);
live.sync(store.ledger().settings(), store.base_currency().code());
eprintln!(
"ledger-mcp: company {:?} (settings from {})",
live.current().company.name,
if live.is_journaled() {
"the books"
} else {
"the settings file"
}
);
let live = std::sync::Arc::new(std::sync::Mutex::new(live));
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, live))?;
return Ok(());
}
eprintln!("ledger-mcp: store verified, serving over stdio");
let server = BooksServer::new(store, ReceiptPolicy::default()).with_live_settings(live);
rt.block_on(async {
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
let running = server.serve((input, output)).await?;
let _ = running.waiting().await;
let _ = writer.await;
Ok::<(), Box<dyn std::error::Error>>(())
})
}
/// Serve MCP over Streamable HTTP (for network clients).
async fn serve_http(
addr: String,
store: Store,
live: std::sync::Arc<std::sync::Mutex<settings::Live>>,
) -> Result<(), Box<dyn std::error::Error>> {
use rmcp::transport::streamable_http_server::{
StreamableHttpService, session::local::LocalSessionManager,
};
use std::sync::Arc;
// One store for every connection: the async mutex keeps it
// single-writer, and tool bodies run on blocking threads (server.rs).
let store = server::SharedStore::new(store);
let service = StreamableHttpService::new(
move || {
let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
.with_live_settings(live.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(())
}
/// Open the books, waiting up to ten seconds for another writer to let go.
/// A restarted server (a client reconnecting) often starts while the old
/// process is still exiting with the lock; waiting turns that race into a
/// short delay. A writer that keeps the books longer is a real conflict,
/// and the refusal says so.
fn open_waiting_for_lock(data_dir: &std::path::Path) -> Result<Store, ledger_store::StoreError> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
match Store::open(data_dir) {
Err(ledger_store::StoreError::Locked(_)) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(100));
}
other => return other,
}
}
}
#[cfg(test)]
mod exit_tests {
use super::Exit;