Files
wanderbooks/crates/ledger-mcp/src/server.rs
T
swaits 57f04dbb0c feat(mcp): payments on terms, bills due, and the agings' tie-out (P4b S6)
books.receive_payment and books.pay_bill take applications, each
document's cash with an optional discount and reason, and a customer
payment may carry the processor's fee; the result names every document it
settled and what reached the account. books.bills_due lists what to pay
and the running total by due date. The agings take detail and return the
control account's tie-out, and books.export writes ar_aging/ap_aging.

The handlers live in tools/settlements.rs (the EM's split, so S4.4's
documents.rs stays free); documents.rs keeps the documents, the void and
the control check. docs/mcp-interface.md documents every changed tool and
the new one; docs/architecture.md names the group; docs/testing.md
records the tests and P12's mutation targets.

Old names (document_id, cash_account) still work; the output keeps
remaining/status for one document. A fee's processor is named in the
description, not tagged as the entry's vendor: ledger tags are entry-wide
and tagging would move the customer's party totals and 1099-NEC.

The handbook's invoices-and-bills section gains the payment, discount,
fee and bounce-replacement procedures (S4.5 can extend it). Over the wire:
one_payment_settles_two_invoices, late_discount_needs_a_reason,
a_fee_posts_gross_to_receivables_and_net_to_the_bank,
card_paid_bill_is_not_1099_nec,
multi_document_payment_counts_once_for_1099_and_party_totals,
overpayment_refused_with_the_excess,
bills_due_orders_by_due_and_discount and the_aging_export_ties_out.

Terms cannot yet be journaled on a document (S4.3) and a bill line's
account is not an MCP field (S4.4), so bill_on_terms_with_discount,
equipment_bill_payment_is_investing_cash and the discount half of
bills_due are core tests until those land; the unit's As-built block
records this.
2026-10-27 12:59:40 -06:00

1963 lines
76 KiB
Rust

//! The MCP server: narrow typed tools over the store, with the receipt
//! policy enforced server-side.
//!
//! ```text
//! GOAL: let an AI agent keep the books through tools that cannot leave
//! them wrong, and say exactly what happened.
//! SOURCE: docs/spec/sources/mcp-tools.md (MCP 2025-06-18 tools: structured
//! results conform to the declared output schema; tool errors are
//! results with isError; "validate all tool inputs");
//! docs/spec/sources/jsonrpc-2.0.md (one response per request, id
//! echoed, result xor error); DECISION (owner, the operating
//! contract in the connect text and handbook, handbook.rs, and
//! docs/mcp-interface.md).
//! INVARIANTS:
//! 1. Every tool's structured result MUST conform to its declared output
//! schema, and the same JSON MUST be in a text block.
//! 2. A refused call (isError) MUST leave the books unchanged: the
//! journal byte for byte.
//! 3. Amounts in MUST be decimal strings, exactly `-?digits[.digits]`
//! (no exponent, sign "+", separators, or spaces); amounts out are
//! decimal strings, never JSON numbers, with at least two places and
//! never rounded.
//! 4. Posting and reversing MUST preview without `confirmed: true`, and a
//! preview MUST change nothing.
//! 5. An entry the receipt policy covers MUST NOT post, or keep posted,
//! without a receipt (crate::policy).
//! 6. A reference (account, vendor, class, party) given as a number
//! that names one thing by id and another by code or name is
//! refused, never guessed.
//! 7. The server MUST answer every request with exactly one response
//! whose id is the request's, and keep serving after any tool error.
//! ```
use crate::handbook;
use crate::policy::ReceiptPolicy;
use crate::settings::{Live, Settings};
use crate::tin_files;
use crate::types::*;
use chrono::NaiveDate;
use ledger_core::{
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
Ledger, Money, NewAsset, PeriodStatus, Side, depreciation_schedule, reports,
};
use ledger_import::ReportMeta;
use ledger_package::{csv_escape, form_1099_label, money, parse_date, tin_kind, vault_tin};
use ledger_store::Store;
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::{
Implementation, ListResourcesResult, ReadResourceRequestParams, ReadResourceResponse,
ReadResourceResult, Resource, ResourceContents, ServerConfig,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::{ServerHandler, tool, tool_handler, tool_router};
use rust_decimal::Decimal;
use std::sync::{Arc, Mutex};
/// The server. One store, one receipt policy, shared across tool calls.
/// `Store` is `!Sync`, so access is serialized through a mutex; the server
/// is single-writer by construction. Tool bodies (synchronous disk and
/// SQLite work) run on tokio's blocking threads, never on the async
/// workers, so a long call can't stall the transport ([`run_blocking`]).
/// Built only from a book as the books server serves it ([`Served`]): the
/// one way books are served (plan D-44).
#[derive(Clone)]
pub struct BooksServer {
store: Arc<SharedStore>,
settings: Arc<Mutex<Live>>,
/// The book's access log: every call is checked against its caller's
/// token and recorded (access.rs).
access: Arc<ledger_store::access::AccessLog>,
/// The book's uploads (`books.begin_upload`).
uploads: ledger_store::uploads::Uploads,
/// The files the books made for agents, waiting to be fetched.
exports: ledger_store::exports::Exports,
/// The books' backups (backup.rs).
backups: Arc<crate::backup::Backups>,
/// How these books are kept from agents (plan 3.8, P9a): what
/// `books.start` reports.
isolation: Isolation,
}
/// How the books are kept from agents (plan 3.8, 2.1; D-32, D-51).
///
/// Locally the server runs as the human and the books are hidden, not
/// locked: `SameUser`. On a machine of the owner's the server runs as a
/// dedicated account and agents have no account there: `ServiceUser`.
/// The server never guesses which; `--isolation enforced` says so
/// (`serve --isolation enforced` under the system unit, P9a).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Isolation {
/// Hidden from the agent in the owner's own account, not locked.
SameUser,
/// A separate account on another machine; the agent has none there.
ServiceUser,
}
impl Isolation {
/// What `books.start` says (the strings plan 3.8 names).
pub fn label(self) -> &'static str {
match self {
Self::SameUser => "same_user (advisory)",
Self::ServiceUser => "service_user (enforced)",
}
}
/// What `--isolation` takes: `same_user`, `advisory`, `service_user`
/// or `enforced`.
pub fn parse(text: &str) -> Result<Self, String> {
match text {
"same_user" | "advisory" => Ok(Self::SameUser),
"service_user" | "enforced" => Ok(Self::ServiceUser),
other => Err(format!(
"--isolation takes same_user or enforced, not {other:?}"
)),
}
}
}
/// One book as the books server serves it (local.rs), shared by every
/// session with it: its one store, the settings in force, its access
/// log, uploads, exports and backups. Each session's [`BooksServer`] is
/// made from it ([`BooksServer::new`]).
#[derive(Clone)]
pub(crate) struct Served {
pub(crate) store: Arc<SharedStore>,
pub(crate) settings: Arc<Mutex<Live>>,
pub(crate) access: Arc<ledger_store::access::AccessLog>,
pub(crate) uploads: ledger_store::uploads::Uploads,
pub(crate) exports: ledger_store::exports::Exports,
pub(crate) backups: Arc<crate::backup::Backups>,
pub(crate) isolation: Isolation,
}
impl Served {
/// [`Self::open_with`] for books served the local way
/// ([`Isolation::SameUser`]): how the server's own tests open them.
#[cfg(test)]
pub(crate) fn open(store: Store) -> Result<Self, String> {
Self::open_with(store, Isolation::SameUser)
}
/// What serving the books `store` holds needs, opened beside them:
/// the settings in force (the books' own), the access log, the uploads
/// and exports, the viewer's side channel, and the backups. The books
/// server opens every book this way (local.rs), the only way books are
/// served (plan D-44), saying how the server runs (plan 3.8, P9a).
pub(crate) fn open_with(store: Store, isolation: Isolation) -> Result<Self, String> {
let mut live = Live::own();
live.sync(store.ledger().settings(), store.base_currency().code());
let access = Arc::new(
ledger_store::access::AccessLog::open(store.data_dir())
.map_err(|e| format!("its access log can't be used: {e}"))?,
);
let uploads =
ledger_store::uploads::Uploads::open(store.data_dir(), chrono::Utc::now().timestamp())
.map_err(|e| e.to_string())?;
let exports =
ledger_store::exports::Exports::open(store.data_dir(), chrono::Utc::now().timestamp())
.map_err(|e| e.to_string())?;
// The viewer's side channel, made by the server (ops.rs INVARIANT 2):
// a viewer can show its presence from the start.
ledger_store::ops::prepare(store.data_dir()).map_err(|e| e.to_string())?;
let book_dir = store.data_dir().to_path_buf();
let book_id = book_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let store = SharedStore::new(store);
let backups = Arc::new(crate::backup::Backups::open(
&book_id,
&book_dir,
store.clone(),
)?);
Ok(Self {
store,
settings: Arc::new(Mutex::new(live)),
access,
uploads,
exports,
backups,
isolation,
})
}
}
/// The one store every session shares, and whether it may still be used.
///
/// ```text
/// GOAL: a request that dies half way never leaves the server serving
/// books that differ from their journal.
/// SOURCE: DECISION (review of P0, 2026-10-16): fail closed. The journal
/// is appended before memory changes (docs/receipt-store.md), so a
/// panic between the two leaves memory behind the record (a
/// reused next id would be journaled forever).
/// INVARIANTS:
/// 1. Once a request panics (its blocking task, or while it holds the
/// store), `stopped` is set and never cleared in this process.
/// 2. While `stopped`, every request is refused with STOPPED, before
/// it touches the store; a restart re-verifies the journal.
/// 3. Once `close` begins, no request starts running (each is refused
/// with NOT_RUN_CLOSING, untouched), and `close` returns only when
/// every request that did start has finished its blocking work,
/// whether or not anyone still waits for its answer.
/// ```
pub struct SharedStore {
/// None once let go (`let_go`): its books were replaced by a restore.
store: tokio::sync::Mutex<Option<Store>>,
stopped: std::sync::atomic::AtomicBool,
/// Closing: no new request runs (INVARIANT 3).
closing: std::sync::atomic::AtomicBool,
/// Requests running (from before their blocking task to its end).
running: std::sync::atomic::AtomicUsize,
/// Woken whenever one ends.
ended: tokio::sync::Notify,
/// The books' base currency, fixed at their creation: read without
/// the lock (the connect text names it).
currency: Currency,
}
impl SharedStore {
/// Run `f` on the store under its lock, for a backup (backup.rs):
/// blocking. It reads only, and runs even while the books close (a
/// last backup), never after the server stopped.
pub fn with_store<R>(&self, f: impl FnOnce(&Store) -> R) -> Result<R, String> {
if self.stopped() {
return Err(STOPPED.to_string());
}
let store = self.store.blocking_lock();
let store = store.as_ref().ok_or(STOPPED)?;
Ok(f(store))
}
/// Run `f` on the store under its lock, writing: the owner's format
/// upgrade, after its backup (backup.rs). Blocking. Refused once the
/// books are closing or the server stopped (INVARIANTS 2, 3), counted
/// as a request while it runs, and one that panics stops the server
/// (INVARIANT 1).
pub fn with_store_mut<R>(
self: &Arc<Self>,
f: impl FnOnce(&mut Store) -> R,
) -> Result<R, String> {
if self.stopped() {
return Err(STOPPED.to_string());
}
// Counted before the closing check, as a tool's request is.
let _running = Running::new(self);
if self.closing() {
return Err("the books are closing".into());
}
let mut store = self.store.blocking_lock();
if self.stopped() {
return Err(STOPPED.to_string());
}
let store = store.as_mut().ok_or(STOPPED)?;
/// Stops the server if `f` panics while it holds the store.
struct StopOnPanic<'a>(&'a SharedStore);
impl Drop for StopOnPanic<'_> {
fn drop(&mut self) {
if std::thread::panicking() {
self.0.stop();
}
}
}
let _stop = StopOnPanic(self);
Ok(f(store))
}
/// Run `f` on the store if no one holds it now (books just opened):
/// never waits, so never blocks a runtime's thread.
pub fn try_with_store<R>(&self, f: impl FnOnce(&Store) -> R) -> Option<R> {
let store = self.store.try_lock().ok()?;
store.as_ref().map(f)
}
/// An attachment's name, type and bytes (hash-verified), for a
/// download: blocking (the store's lock, then the file).
/// None when there is no such attachment.
pub fn attachment_file(&self, id: u64) -> Result<Option<(String, String, Vec<u8>)>, String> {
if self.stopped() {
return Err(STOPPED.to_string());
}
let store = self.store.blocking_lock();
let store = store.as_ref().ok_or(STOPPED)?;
let Some(meta) = store
.list_attachments(None)
.map_err(|e| e.to_string())?
.into_iter()
.find(|a| a.id == id)
else {
return Ok(None);
};
let bytes = store.attachment_bytes(id).map_err(|e| e.to_string())?;
Ok(Some((meta.file_name, meta.mime_type, bytes)))
}
/// Whether it stopped taking requests (INVARIANT 2).
pub fn is_stopped(&self) -> bool {
self.stopped()
}
/// Share an open store.
pub fn new(store: Store) -> Arc<Self> {
Arc::new(Self {
currency: store.base_currency(),
store: tokio::sync::Mutex::new(Some(store)),
stopped: std::sync::atomic::AtomicBool::new(false),
closing: std::sync::atomic::AtomicBool::new(false),
running: std::sync::atomic::AtomicUsize::new(0),
ended: tokio::sync::Notify::new(),
})
}
/// Stop new requests from running (INVARIANT 3), and wait until every
/// one already running has ended: its blocking task finished, even if
/// its client hung up.
pub async fn close(&self) {
self.closing
.store(true, std::sync::atomic::Ordering::SeqCst);
loop {
let ended = self.ended.notified();
if self.running.load(std::sync::atomic::Ordering::SeqCst) == 0 {
return;
}
ended.await;
}
}
fn closing(&self) -> bool {
self.closing.load(std::sync::atomic::Ordering::SeqCst)
}
fn stopped(&self) -> bool {
self.stopped.load(std::sync::atomic::Ordering::SeqCst)
}
/// Let go of the books for good (a restore replaces them): close as
/// [`Self::close`], stop, and drop the store, so its writer lock is
/// released while sessions still hold this.
pub async fn let_go(&self) {
self.close().await;
self.stop();
self.store.lock().await.take();
}
/// Stop taking requests (INVARIANT 1): after an internal error, or an
/// access record that couldn't be written (access.rs INVARIANT 3).
pub(crate) fn stop(&self) {
self.stopped
.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
/// What every request gets after an internal error (SharedStore
/// INVARIANT 2).
pub(crate) const STOPPED: &str = "the books server stopped taking requests after an internal error, to protect the books: ask the human to restart the books server (the books are checked again when it starts)";
/// The store, locked for one stretch of synchronous work. Not `Send`, so
/// the compiler refuses any tool future that would hold it across an
/// `.await` (tool futures must be `Send`): the lock is never held while a
/// task is suspended. Dropped by a panic, it stops the server
/// (SharedStore INVARIANT 1).
pub struct StoreGuard<'a> {
/// Holds a store (checked when it was taken).
guard: tokio::sync::MutexGuard<'a, Option<Store>>,
shared: &'a SharedStore,
/// The journal's head when the lock was taken.
before: (u64, String),
_not_send: std::marker::PhantomData<*const ()>,
}
impl Drop for StoreGuard<'_> {
fn drop(&mut self) {
if std::thread::panicking() {
self.shared.stop();
}
// A write while it was held: the head after it, for the call's
// access record (taken under the same lock, so it is this call's).
let Some(store) = self.guard.as_ref() else {
return;
};
let after = store.head();
if after != self.before {
WROTE.with(|w| *w.borrow_mut() = Some(after));
}
}
}
impl std::ops::Deref for StoreGuard<'_> {
type Target = Store;
fn deref(&self) -> &Store {
self.guard.as_ref().expect("a guard holds a store")
}
}
impl std::ops::DerefMut for StoreGuard<'_> {
fn deref_mut(&mut self) -> &mut Store {
self.guard.as_mut().expect("a guard holds a store")
}
}
/// Run a request's future to completion on one of tokio's blocking
/// threads (`spawn_blocking`), where the store lock is taken with
/// `blocking_lock`. The future is driven here, by parking this thread
/// until it is woken, not by the runtime: tool bodies are synchronous
/// work, and `blocking_lock` may not be called from the runtime's own
/// `block_on`. A stopped server runs nothing; a request that panics stops
/// it (SharedStore), and gets STOPPED, never the panic's own text.
async fn run_blocking<T, F>(shared: &Arc<SharedStore>, make: F) -> Result<T, rmcp::ErrorData>
where
T: Send + 'static,
F: FnOnce() -> std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>> + Send + 'static,
{
if shared.stopped() {
return Err(not_run_stopped());
}
// SharedStore INVARIANT 3: counted before the closing check, so a
// close never misses it; the count rides in the blocking task, which
// runs to its end even when this future is dropped.
let running = Running::new(shared);
if shared.closing() {
return Err(not_run_closing());
}
let joined = tokio::task::spawn_blocking(move || {
let _running = running;
struct Unpark(std::thread::Thread);
impl std::task::Wake for Unpark {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
let waker = std::task::Waker::from(Arc::new(Unpark(std::thread::current())));
let mut cx = std::task::Context::from_waker(&waker);
let mut fut = make();
loop {
if let std::task::Poll::Ready(out) = fut.as_mut().poll(&mut cx) {
return out;
}
std::thread::park();
}
})
.await;
joined.map_err(|e| {
if e.is_panic() {
shared.stop();
}
rmcp::ErrorData::internal_error(STOPPED, None)
})
}
/// A request running, counted in its store (SharedStore INVARIANT 3).
struct Running(Arc<SharedStore>);
impl Running {
fn new(shared: &Arc<SharedStore>) -> Self {
shared
.running
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Self(shared.clone())
}
}
impl Drop for Running {
fn drop(&mut self) {
self.0
.running
.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
self.0.ended.notify_waiters();
}
}
/// The JSON-RPC error code of a request refused because the books are
/// closing: it didn't run, so a client may send it again once they are
/// open (the agents' bridge does).
pub const NOT_RUN_CLOSING_CODE: i32 = -32050;
/// The error code of a request not run because the server had stopped
/// (SharedStore INVARIANT 2), told apart from one that ran and failed.
pub const NOT_RUN_STOPPED_CODE: i32 = -32051;
fn not_run_stopped() -> rmcp::ErrorData {
rmcp::ErrorData::new(rmcp::model::ErrorCode(NOT_RUN_STOPPED_CODE), STOPPED, None)
}
/// A tool's answer that is a refusal in words.
fn tool_error(text: String) -> rmcp::model::CallToolResponse {
rmcp::model::CallToolResponse::Complete(rmcp::model::CallToolResult::error(vec![
rmcp::model::ContentBlock::text(text),
]))
}
/// What a request refused while closing is told.
pub const NOT_RUN_CLOSING: &str = "the books are closing: this call wasn't run";
fn not_run_closing() -> rmcp::ErrorData {
rmcp::ErrorData::new(
rmcp::model::ErrorCode(NOT_RUN_CLOSING_CODE),
NOT_RUN_CLOSING,
None,
)
}
impl BooksServer {
/// The settings that name account `code`, by key.
fn config_mentions(&self, code: &str) -> Vec<String> {
let mut out = Vec::new();
let settings = self.settings();
for (key, codes) in [
("books.cash_accounts", &settings.roles.cash),
("books.financing_accounts", &settings.roles.financing),
("books.investing_accounts", &settings.roles.investing),
("books.close_sweep", &settings.company.close_sweep),
(
"policy.evidence.required_accounts",
&settings.policy.receipt_required_accounts,
),
(
"policy.evidence.exempt_accounts",
&settings.policy.receipt_exempt_accounts,
),
] {
if codes.iter().any(|c| c == code) {
out.push(key.to_string());
}
}
if settings.company.close_into.as_deref() == Some(code) {
out.push("books.close_into".to_string());
}
let members = "company.members";
for m in &settings.company.members {
if m.capital_account == code {
out.push(format!("{members} {} capital_account", m.name));
}
if m.distribution_account.as_deref() == Some(code) {
out.push(format!("{members} {} distribution_account", m.name));
}
}
out
}
/// A session's server for the book `served` serves.
pub(crate) fn new(served: &Served) -> Self {
Self {
store: served.store.clone(),
settings: served.settings.clone(),
access: served.access.clone(),
uploads: served.uploads.clone(),
exports: served.exports.clone(),
backups: served.backups.clone(),
isolation: served.isolation,
}
}
fn live(&self) -> std::sync::MutexGuard<'_, Live> {
// A panic while holding it can't leave the settings half-written
// (they're swapped whole), so a poisoned lock is still good.
self.settings.lock().unwrap_or_else(|e| e.into_inner())
}
/// Every tool the server serves.
pub(crate) fn tool_router() -> rmcp::handler::server::router::tool::ToolRouter<Self> {
Self::kept_router()
+ Self::access_router()
+ Self::files_router()
+ Self::backup_router()
+ Self::instruments_router()
+ Self::projects_router()
+ Self::documents_router()
+ Self::settlements_router()
+ Self::entries_router()
+ Self::reports_router()
+ Self::assets_router()
+ Self::schedules_router()
+ Self::periods_router()
+ Self::reconciliation_router()
+ Self::parties_router()
+ Self::attachments_router()
+ Self::settings_router()
+ Self::chart_router()
+ Self::payroll_router()
+ Self::payouts_router()
+ Self::tax_router()
}
/// The settings in force for this request: the books' own, taken from
/// their journal whenever the store is held (settings.rs INVARIANT 1).
fn settings(&self) -> Arc<Settings> {
self.live().current()
}
/// Whether the books declared the journal feature
/// `document_line_projects` (the handbook's projects section says how
/// an invoice's or bill's lines reach a project by it).
fn document_line_projects(&self) -> bool {
self.lock().is_ok_and(|s| {
s.journal_format()
.has_feature(ledger_store::format::DOCUMENT_LINE_PROJECTS)
})
}
/// The books' base currency code, e.g. "USD".
fn currency(&self) -> &str {
self.store.currency.code()
}
/// What omitted dates mean today, for these books (rules.md B-30): the
/// one resolver every report tool, `books.show` and the viewer use.
fn periods(&self) -> ledger_core::calendar::PeriodDefaults {
ledger_core::calendar::period_defaults(&self.settings().company, today())
}
/// A period report's range: the dates as given, each omitted one the
/// default (rules.md B-30); a start after the end, given or derived,
/// is refused.
fn statement_range(
&self,
start: Option<&str>,
end: Option<&str>,
) -> Result<(NaiveDate, NaiveDate), String> {
ledger_package::statement_range(&self.settings().company, start, end, today())
}
/// A point-in-time report's day: as given, else today (rules.md B-30).
fn as_of(&self, as_of: Option<&str>) -> Result<NaiveDate, String> {
ledger_package::as_of_date(&self.settings().company, as_of, today())
}
/// Profit and loss for a range (see [`Self::statement_range`]),
/// optionally one class.
fn pnl_statement(
&self,
ledger: &Ledger,
start: Option<&str>,
end: Option<&str>,
class_id: Option<ledger_core::ClassId>,
) -> Result<ledger_core::ProfitAndLossStatement, String> {
ledger_package::pnl_statement(
ledger,
&self.settings().company,
start,
end,
class_id,
today(),
)
}
/// Balance sheet as of a day (see [`Self::as_of`]).
fn bs_statement(
&self,
ledger: &Ledger,
as_of: Option<&str>,
) -> Result<ledger_core::BalanceSheetStatement, String> {
ledger_package::bs_statement(ledger, &self.settings().company, as_of, today())
}
/// The cash flow statement from account codes, each list defaulting to
/// the `[books]` roles when the call passes none.
fn cash_flow_for(
&self,
ledger: &Ledger,
start: Option<&str>,
end: Option<&str>,
cash: &[String],
financing: &[String],
investing: &[String],
) -> Result<ledger_core::reports::CashFlowStatement, String> {
ledger_package::cash_flow_for(
ledger,
&package_settings(&self.settings()),
start,
end,
cash,
financing,
investing,
today(),
)
}
/// Header and provenance for a rendered report.
fn report_meta(&self, store: &Store) -> ReportMeta {
ledger_package::report_meta(&self.settings().company, today(), store.journal_seq())
}
/// Members as reports name them (empty unless a multi-member LLC).
fn report_members(&self) -> Vec<ledger_import::Member> {
ledger_package::report_members(&self.settings().company)
}
/// Members with their capital accounts resolved in the chart, for the
/// statement of members' equity.
fn member_capitals(&self, ledger: &Ledger) -> Result<Vec<ledger_core::MemberCapital>, String> {
if self.settings().company.members.is_empty() {
return Err(
"no members configured: set company.members (name, share, capital_account for each)"
.to_string(),
);
}
ledger_package::member_capitals(ledger, &self.settings().company.members)
}
/// Where a period close goes, from the settings: members' capital
/// accounts by share (with their distribution accounts swept in) for a
/// partnership; else `close_into` (Retained Earnings when unset or
/// `3999`) with `close_sweep` zeroed into it.
fn close_target(&self, ledger: &Ledger) -> Result<ledger_core::CloseTo, String> {
let code = |c: &str| -> Result<AccountId, String> {
ledger
.chart()
.find_by_code(c)
.map(|a| a.id)
.ok_or_else(|| format!("the settings name unknown account {c}"))
};
if !self.settings().company.members.is_empty() {
return Ok(ledger_core::CloseTo::Members(
self.member_capitals(ledger)?
.into_iter()
.map(|m| ledger_core::MemberClose {
capital: m.capital_account,
share: m.share,
distributions: m.distribution_account,
})
.collect(),
));
}
let sweep = self
.settings()
.company
.close_sweep
.iter()
.map(|c| code(c))
.collect::<Result<Vec<_>, _>>()?;
Ok(match self.settings().company.close_into.as_deref() {
None | Some(ledger_core::RETAINED_EARNINGS_CODE) => {
ledger_core::CloseTo::RetainedEarnings { sweep }
}
Some(c) => ledger_core::CloseTo::Account {
into: code(c)?,
sweep,
},
})
}
/// The fiscal year's earnings through `as_of`, for the balance sheet:
/// net income (loss) year to date (closing entries excluded), and the
/// part of it not yet closed into equity: the income after the last
/// close (closes are cumulative), from no earlier than the year's start.
fn year_earnings(
&self,
ledger: &Ledger,
as_of: NaiveDate,
) -> Result<ledger_import::YearEarnings, String> {
ledger_package::year_earnings(ledger, &self.settings().company, as_of)
}
/// Cash and card accounts due for reconciliation
/// (ledger_store::reconcilable, the viewer's Needs-attention rule):
/// the cash accounts (`[books] cash_accounts`, else assets named for
/// cash), the card accounts (ledger_core::parties::card_accounts: the
/// business-card payment methods' liabilities, else liabilities named
/// "card", not "gift", before their first statement), and every other
/// liability with a recorded
/// statement, with posted activity through the end of last month, not
/// reconciled through it.
///
/// ```text
/// GOAL: the monthly close's reconciliation step can't be forgotten,
/// and the digest and the viewer agree on what is due.
/// SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs
/// attention: an account with activity not reconciled through
/// last month's end).
/// ```
fn unreconciled_accounts(
&self,
store: &Store,
today: NaiveDate,
) -> Result<Vec<UnreconciledAccountOut>, String> {
use ledger_store::reconcilable;
let ledger = store.ledger();
let cash = reconcilable::cash_accounts(ledger, &self.settings().roles.cash);
let statements = store.statements(None).map_err(|e| e.to_string())?;
let reconciliations = store.reconciliations(None).map_err(|e| e.to_string())?;
let mut out = Vec::new();
for id in reconcilable::reconcilable_accounts(ledger, &cash, &statements) {
let Some(due) = reconcilable::reconciliation_due(ledger, &reconciliations, id, today)
else {
continue;
};
let (code, name) = code_name(store, id);
out.push(UnreconciledAccountOut {
next_step: format!(
"file the {code} statement through {due} (books.file_document category statement), books.record_statement, mark the lines it shows (books.mark_cleared), then books.reconcile with statement_attachment and confirmed: true"
),
code,
name,
reconciled_through: reconcilable::reconciled_through(&reconciliations, id)
.map(|d| d.to_string()),
due_through: due.to_string(),
});
}
Ok(out)
}
/// The TIN vault's directory: `vault/` in the book's own folder in
/// the data root.
fn vault_dir(&self, store: &Store) -> std::path::PathBuf {
store.data_dir().join(ledger_store::root::VAULT_DIR)
}
/// The vault's key file: the book's own `keys/legacy.key`.
fn key_file(&self, store: &Store) -> std::path::PathBuf {
store
.data_dir()
.join(ledger_store::root::KEYS_DIR)
.join(ledger_store::root::LEGACY_KEY_FILE)
}
/// Refuse bytes on their way into the books (books.attach_receipt,
/// books.file_document, books.set_w9's file) if they carry a full
/// TIN: a file the server read or wrote with TINs, or text holding one
/// the vault holds or one printed as an SSN or EIN is (tin_files.rs
/// INVARIANT 3), or text probing for one (INVARIANT 4).
fn refuse_tin_file(
&self,
store: &Store,
bytes: &[u8],
mime_type: &str,
file_name: &str,
) -> Result<(), String> {
tin_files::refuse(
&self.vault_dir(store),
bytes,
mime_type,
file_name,
|| known_tin_last4(store),
|| self.vault_tins(store),
self.company_ein(),
)
}
/// The company's own EIN, as its settings hold it, as nine digits:
/// printed 2-7 in a filed document, it is no person's TIN
/// (tin_files.rs INVARIANT 3b).
fn company_ein(&self) -> Option<u32> {
let settings = self.settings();
let (_, ein) = settings.company.profile.iter().find(|(k, _)| k == "ein")?;
let digits: String = ein.chars().filter(char::is_ascii_digit).collect();
(digits.len() == 9).then(|| digits.parse().ok()).flatten()
}
/// Every full TIN the vault holds for a party of the books, for the
/// filing scan only (tin_files.rs INVARIANT 3a): never returned or
/// logged. None when the vault can't be opened (its key missing or
/// bad), and none for a record that can't be read: the scan then
/// falls back to the printed shapes.
fn vault_tins(&self, store: &Store) -> Vec<ledger_vault::Tin> {
let Ok(vault) = self.open_vault(store, false) else {
return Vec::new();
};
store
.ledger()
.parties()
.filter_map(|p| vault.tin(p.id).ok().flatten())
.collect()
}
/// The signed W-9 a call gives (its upload, `upload_id`), for the vault:
/// a PDF, PNG, or JPEG by its own first bytes, whatever it is called.
fn w9_file(
&self,
p: &SetW9Params,
upload_id: &str,
) -> Result<(ledger_vault::W9File, Option<ledger_store::uploads::Claim>), String> {
let file = self.take_file(files::Given {
upload_id,
purposes: files::W9,
})?;
// Not recorded here: set_w9 records it once the call can only
// succeed, just before the vault write (tin_files.rs INVARIANT 1),
// so a refused call never marks an ordinary file.
let mime_type = if file.bytes.starts_with(b"%PDF-") {
"application/pdf"
} else if file.bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if file.bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
"image/jpeg"
} else {
return Err("the W-9 is a PDF, PNG, or JPEG".into());
};
let file_name = p.w9_file_name.clone().unwrap_or(file.file_name);
if let Some(why) = ledger_store::uploads::file_name_problem(&file_name) {
return Err(why);
}
Ok((
ledger_vault::W9File {
file_name,
mime_type: mime_type.to_string(),
bytes: file.bytes,
},
file.claim,
))
}
/// A CSV's text, from its upload (books.begin_upload purpose import),
/// and the upload's hold.
fn csv_given(
&self,
upload_id: &str,
) -> Result<(String, Option<ledger_store::uploads::Claim>), String> {
let file = self.take_file(files::Given {
upload_id,
purposes: files::IMPORTED,
})?;
let text =
String::from_utf8(file.bytes).map_err(|_| "the CSV isn't UTF-8 text".to_string())?;
Ok((text, file.claim))
}
/// Record a file read or written with full TINs (tin_files.rs
/// INVARIANT 2), before it is written out: every session, and every
/// later server for these books, refuses it.
fn wrote_tins(&self, store: &Store, bytes: &[u8]) -> Result<(), String> {
tin_files::record(&self.tin_record_dir(store)?, bytes)
}
/// Where records of files with TINs go: the vault's folder, made
/// private first, as the vault itself makes it.
fn tin_record_dir(&self, store: &Store) -> Result<std::path::PathBuf, String> {
ledger_store::root::private_book_folder(store.data_dir(), ledger_store::root::VAULT_DIR)
.map_err(|e| format!("the TIN vault can't be written: {e}"))?;
Ok(self.vault_dir(store))
}
/// Open the TIN vault with the book's own key file, creating the key
/// only when `create` (storing a TIN); reading never creates one.
fn open_vault(&self, store: &Store, create: bool) -> Result<ledger_vault::Vault, String> {
let key = self.key_file(store);
// Its folder and the vault's are made private when a TIN is first
// stored.
if create {
for folder in [ledger_store::root::KEYS_DIR, ledger_store::root::VAULT_DIR] {
ledger_store::root::private_book_folder(store.data_dir(), folder)
.map_err(|e| format!("the TIN vault can't store anything yet: {e}"))?;
}
}
// No message names the key file or the books' folders.
ledger_vault::Vault::open(&self.vault_dir(store), &key, create).map_err(|e| match e {
ledger_vault::Error::NoKey(_) => "the TIN vault is locked: its key file is missing (restore it from the human's password manager)".to_string(),
ledger_vault::Error::BadKey(_) => "the TIN vault's key file is not a vault key (64 hex digits): restore it from the human's password manager".to_string(),
ledger_vault::Error::Io(e) => format!("the TIN vault can't be read: {}", e.kind()),
other => other.to_string(),
})
}
/// Lock the store. Called only on a blocking thread (every request
/// runs through [`run_blocking`]); `blocking_lock` panics on an async
/// worker.
fn lock(&self) -> Result<StoreGuard<'_>, String> {
if self.store.stopped() {
return Err(STOPPED.to_string());
}
let guard = self.store.store.blocking_lock();
// A request that panicked while this one waited stopped it; books
// let go hold no store.
if self.store.stopped() {
return Err(STOPPED.to_string());
}
let Some(store) = guard.as_ref() else {
return Err(STOPPED.to_string());
};
// Books that keep their own settings: take any change (crate::
// settings INVARIANT 1).
let timezone = {
let mut live = self.live();
live.sync(store.ledger().settings(), self.store.currency.code());
live.current().timezone.clone()
};
// "Today" from here on is the zone in force now (a change another
// session made while this request waited included).
TIMEZONE.with(|z| *z.borrow_mut() = timezone);
let before = store.head();
Ok(StoreGuard {
guard,
shared: &self.store,
before,
_not_send: std::marker::PhantomData,
})
}
/// Fail with the structured `receipt_required` error when the receipt
/// policy requires this posted entry to keep at least one receipt.
/// Drafts pass (posting enforces the policy later); reversals pass (they
/// are exempt: the original carries the evidence).
fn ensure_receipt_not_required(&self, store: &Store, entry_id: u64) -> Result<(), String> {
let entry = store
.ledger()
.get_entry(entry_id)
.ok_or_else(|| format!("unknown entry {entry_id}"))?;
if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() {
return Ok(());
}
let split_group = self
.settings()
.policy
.split_group(store.ledger(), entry_id, entry);
if !self
.settings()
.policy
.receipt_required(store.ledger(), entry)
&& split_group.is_empty()
{
return Ok(());
}
// A draft's debits may be past exact arithmetic (rules.md B-28):
// then the refusal says so in place of the sum.
let (total, debits) = match ReceiptPolicy::entry_debits(entry) {
Ok(t) => (money(t), t.to_string()),
Err(e) => (e.to_string(), e.to_string()),
};
Err(ReceiptRequiredError {
code: "receipt_required".to_string(),
message: format!(
"entry {entry_id} moves {total} in debits ({}); unlinking its last receipt would leave it without one.",
self.settings().policy.describe()
),
entry_id: Some(entry_id),
asset_id: None,
debits,
policy: self.settings().policy.describe(),
remedy_tool: "books.attach_receipt".to_string(),
remedy_hint: format!(
"attach the replacement receipt to entry {entry_id} with books.attach_receipt first, then retry books.unlink_receipt"
),
split_group,
}
.to_tool_error())
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// How the viewer reads `report`'s dates with these options, or why it
/// would ignore one of them (`books.show` refuses what its target
/// ignores). Splits are the P&L's, the balance sheet's and the cash
/// flow's; comparisons theirs and the tax summary's, in one total column;
/// a balance sheet in one total column is as of its end, unless compared
/// with the period before (which starts the day before start).
fn show_report_dates(
report: &str,
columns: Option<&str>,
compare: Option<&str>,
) -> Result<ShownDates, String> {
let split = columns.is_some_and(|c| c != "total");
if columns.is_some() && !matches!(report, "profit_and_loss" | "balance_sheet" | "cash_flow") {
return Err(format!(
"columns is for profit_and_loss, balance_sheet and cash_flow, not {report}"
));
}
if compare.is_some() {
if !matches!(
report,
"profit_and_loss" | "balance_sheet" | "cash_flow" | "tax_summary"
) {
return Err(format!(
"compare is for profit_and_loss, balance_sheet, cash_flow and tax_summary, not {report}"
));
}
if split {
return Err(
"compare is for a report in one total column: a report split into columns shows no comparison"
.into(),
);
}
}
Ok(match report {
"trial_balance" | "ar_aging" | "ap_aging" | "fixed_asset_register" => ShownDates::AsOf,
"vendor_1099" => ShownDates::Year,
"balance_sheet" if !split && compare != Some("prior_period") => ShownDates::AsOf,
_ => ShownDates::Period,
})
}
/// "invoice:7" or "bill:12" as a document.
fn parse_doc_ref(r: &str) -> Result<ledger_core::DocRef, String> {
let bad = || format!("bad ref {r:?}: expected invoice:N or bill:N (from books.documents)");
let (kind, id) = r.trim().split_once(':').ok_or_else(bad)?;
let id = id.trim().parse::<u64>().map_err(|_| bad())?;
doc_ref(kind, id).map_err(|_| bad())
}
/// Resolve a vendor by id or name (case-insensitive); a number that is
/// one vendor's id and another's name is refused (INVARIANT 6).
fn resolve_vendor(store: &Store, vendor: &str) -> Result<u64, String> {
let by_id = vendor
.parse::<u64>()
.ok()
.filter(|id| store.ledger().get_vendor(*id).is_some());
let matches: Vec<u64> = store
.ledger()
.list_vendors()
.into_iter()
.filter(|v| v.name.eq_ignore_ascii_case(vendor.trim()))
.map(|v| v.id)
.collect();
if let Some(id) = by_id {
return match matches.as_slice() {
[] => Ok(id),
[m] if *m == id => Ok(id),
_ => Err(format!(
"vendor {vendor:?} is ambiguous: it is vendor {id}'s id and another vendor's name; pass the id"
)),
};
}
match matches.as_slice() {
[id] => Ok(*id),
[] => Err(format!(
"unknown vendor {vendor:?}: add it with books.add_vendor"
)),
_ => Err(format!("several vendors are named {vendor:?}: pass the id")),
}
}
/// Mask any full TIN in a refusal (`ledger_core::parties::redact_full_tins`):
/// a refusal that quotes its input (an unknown party, a bad amount, a
/// parameter of the wrong type) never echoes a TIN the agent put in the
/// wrong field. No tool output, error, or log holds a full TIN
/// (docs/spec/rules.md B-26); a successful result is left alone, since
/// the one that may (`books.vendor_1099` with `include_tins`) is one.
fn redact_refusal(
out: Result<rmcp::model::CallToolResponse, rmcp::ErrorData>,
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
use ledger_core::parties::{has_full_tin, redact_full_tins};
match out {
Err(e) => Err(redact_error(e)),
Ok(rmcp::model::CallToolResponse::Complete(mut r)) if r.is_error == Some(true) => {
for c in &mut r.content {
if let rmcp::model::ContentBlock::Text(t) = c
&& has_full_tin(&t.text)
{
t.text = redact_full_tins(&t.text).into_owned();
}
}
if let Some(v) = r.structured_content.as_mut() {
redact_value(v);
}
Ok(rmcp::model::CallToolResponse::Complete(r))
}
other => other,
}
}
/// An error with any full TIN in its message or data masked (B-26): a
/// tool's, or a resource read's, which quotes the URI it was given.
fn redact_error(mut e: rmcp::ErrorData) -> rmcp::ErrorData {
use ledger_core::parties::{has_full_tin, redact_full_tins};
if has_full_tin(&e.message) {
e.message = redact_full_tins(&e.message).into_owned().into();
}
if let Some(d) = e.data.as_mut() {
redact_value(d);
}
e
}
/// Every string in `v` with any full TIN masked.
fn redact_value(v: &mut serde_json::Value) {
use ledger_core::parties::{has_full_tin, redact_full_tins};
use serde_json::Value;
match v {
Value::String(t) if has_full_tin(t) => *t = redact_full_tins(t).into_owned(),
Value::Array(a) => a.iter_mut().for_each(redact_value),
Value::Object(o) => o.values_mut().for_each(redact_value),
_ => {}
}
}
/// The resources the server lists (resources/list): fixed reports, the
/// digest, and the handbook, by section.
fn resource_list() -> Vec<Resource> {
let mk = |uri: &str, name: &str, title: &str, description: &str| {
let mut r = Resource::new(uri, name);
r.title = Some(title.to_string());
r.description = Some(description.to_string());
r.mime_type = Some("application/json".to_string());
r
};
let resources = vec![
mk(
"books://start",
"start",
"Start here",
"The books in brief: rules in force, settings, conventions, open items, next steps (books.start).",
),
mk(
"books://settings",
"settings",
"Settings",
"Every setting the books know, its value and who set it (books.settings).",
),
mk(
"books://conventions",
"conventions",
"Conventions",
"How this company keeps its books: templates, rules, policies, close steps (books.conventions).",
),
mk(
"books://chart",
"chart",
"Chart of accounts",
"All accounts with codes, types, and hierarchy.",
),
mk(
"books://reports/trial-balance",
"trial-balance",
"Trial balance",
"The trial balance as of today (debits equal credits): books.trial_balance with its as_of omitted.",
),
mk(
"books://reports/balance-sheet",
"balance-sheet",
"Balance sheet",
"Assets, liabilities, equity, as of today: books.balance_sheet with its as_of omitted.",
),
mk(
"books://reports/profit-and-loss",
"profit-and-loss",
"Profit and loss",
"Revenue, expenses, net income for the fiscal year to date (company.fiscal_year_start through today): books.profit_and_loss with its dates omitted.",
),
mk(
"books://digest",
"digest",
"Activity digest",
"What changed in the books, since the start of this month or ?since=YYYY-MM-DD.",
),
mk(
handbook::URI,
"handbook",
"Handbook",
"The bookkeeping guide: its sections, each at books://handbook/{id}.",
),
];
let mut resources = resources;
for (id, md) in handbook::SECTIONS {
let mut r = mk(
&format!("{}/{id}", handbook::URI),
&format!("handbook-{id}"),
handbook::title(md),
"A section of the bookkeeping guide.",
);
r.mime_type = Some("text/markdown".to_string());
resources.push(r);
}
resources
}
/// The subject kinds, for messages.
const SUBJECT_KINDS: &str = "entry, account, asset, party, project, invoice, bill, period, payroll_run, schedule, reconciliation, company";
/// Preview lines with resulting balances for raw (account, side, amount) lines.
fn preview_lines_for(
store: &Store,
lines: &[(AccountId, Side, Money)],
) -> Result<Vec<PreviewLine>, String> {
let ledger = store.ledger();
let mut out = Vec::with_capacity(lines.len());
for (account_id, side, amount) in lines {
let account = ledger
.chart()
.get(*account_id)
.ok_or_else(|| format!("unknown account {account_id}"))?;
let before = ledger
.account_balance(*account_id)
.map_err(|e| e.to_string())?;
let normal = account.account_type.normal_side();
let delta = if *side == normal {
amount.amount
} else {
-amount.amount
};
let after = before + delta;
out.push(PreviewLine {
account_id: *account_id,
code: account.code.clone(),
name: account.name.clone(),
side: match side {
Side::Debit => "debit".to_string(),
Side::Credit => "credit".to_string(),
},
amount: money(amount.amount),
balance_before: money(if before.is_zero() {
Decimal::ZERO
} else {
before
}),
balance_after: money(if after.is_zero() {
Decimal::ZERO
} else {
after
}),
});
}
Ok(out)
}
thread_local! {
/// The token of the request running on this thread (books served from
/// the data root), its id and name, for what is bound to it (its
/// uploads) and the commands it is given.
static CALLER: std::cell::RefCell<Option<(String, String)>> = const { std::cell::RefCell::new(None) };
/// The journal's head after the last write made by the request
/// running on this thread (StoreGuard's drop), for its access record.
static WROTE: std::cell::RefCell<Option<(u64, String)>> = const { std::cell::RefCell::new(None) };
/// The company's time zone for the request running on this thread
/// (set at its start from the settings in force: one version per
/// request), so every "today" in it agrees.
static TIMEZONE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
}
/// Poll `fut` with "today" in the request's zone on this thread, each
/// time it runs: the request is driven on its own thread (run_blocking),
/// so the zone is the request's alone. It starts as `timezone`; when the
/// request locks the books the zone in force then replaces it (lock), and
/// that carries over to the request's later polls.
async fn in_timezone_async<T>(
timezone: Option<String>,
fut: impl std::future::Future<Output = T>,
) -> T {
let mut fut = std::pin::pin!(fut);
let mut zone = timezone;
std::future::poll_fn(move |cx| {
let before = TIMEZONE.with(|z| z.replace(zone.take()));
let polled = fut.as_mut().poll(cx);
zone = TIMEZONE.with(|z| z.replace(before));
polled
})
.await
}
/// Today in the company's time zone (`company.timezone`, for the request
/// running), else the computer's. Not UTC: in the evening (US time) UTC
/// is already tomorrow, and "through today" must not include it.
fn today() -> NaiveDate {
TIMEZONE.with(|z| ledger_core::settings::today_in(z.borrow().as_deref()))
}
/// The `since` of a `books://digest` read: the query's `since=`, or, bare
/// or with an empty `since=`, the first day of `today`'s month (this
/// month so far).
fn digest_since(query: &str, today: NaiveDate) -> String {
query
.split('&')
.find_map(|kv| kv.strip_prefix("since="))
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
chrono::Datelike::with_day(&today, 1)
.unwrap_or(today)
.to_string()
})
}
/// How the viewer reads a report's dates (ledger-gui pages/reports.rs):
/// over a period, as of its end, or for its end's calendar year.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ShownDates {
/// From start through end.
Period,
/// As of end.
AsOf,
/// For end's calendar year.
Year,
}
// ---------------------------------------------------------------------------
// Tools
// ---------------------------------------------------------------------------
#[path = "tools/access.rs"]
mod access_tools;
#[path = "tools/backup.rs"]
mod backup_tools;
#[path = "tools/files.rs"]
mod files;
#[path = "tools/instruments.rs"]
mod instruments;
#[path = "tools/kept.rs"]
mod kept;
#[path = "tools/projects.rs"]
mod projects;
pub use kept::SettingKeyOut;
// The tool groups: one file each (plan/served.md 8.2 U0a). A unit adds a
// handler only to its own group's file; the shared helpers are in
// tools/helpers.rs, re-exported below.
#[path = "tools/assets.rs"]
mod assets;
#[path = "tools/attachments.rs"]
mod attachments;
#[path = "tools/chart.rs"]
mod chart;
#[path = "tools/documents.rs"]
mod documents;
#[path = "tools/entries.rs"]
mod entries;
#[path = "tools/helpers.rs"]
mod helpers;
#[path = "tools/parties.rs"]
mod parties;
#[path = "tools/payouts.rs"]
mod payouts;
#[path = "tools/payroll.rs"]
mod payroll;
#[path = "tools/periods.rs"]
mod periods;
#[path = "tools/reconciliation.rs"]
mod reconciliation;
#[path = "tools/reports.rs"]
mod reports_tools;
#[path = "tools/schedules.rs"]
mod schedules;
#[path = "tools/settings.rs"]
mod settings_tools;
#[path = "tools/settlements.rs"]
mod settlements;
#[path = "tools/tax.rs"]
mod tax;
// The shared helpers are re-exported so every group file reaches them
// through `use super::*`, and no group file reaches into another.
pub(crate) use helpers::*;
// ---------------------------------------------------------------------------
// ServerHandler
// ---------------------------------------------------------------------------
impl BooksServer {
/// Render a resource URI to its JSON text. Used by `read_resource`.
async fn resource_text(&self, uri: &str) -> Result<String, String> {
// Split off the query string for URIs like books://digest?since=...
let (path, query) = match uri.split_once('?') {
Some((p, q)) => (p, q),
None => (uri, ""),
};
match path {
"books://start" => {
let Json(out) = self.start(Parameters(kept::StartParams::default())).await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://settings" => {
let Json(out) = self.settings_tool().await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://conventions" => {
let Json(out) = self
.conventions(Parameters(kept::ConventionsParams::default()))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://chart" => {
let Json(out) = self.chart().await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://reports/trial-balance" => {
let Json(out) = self
.trial_balance(Parameters(TrialBalanceParams::default()))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://reports/balance-sheet" => {
let Json(out) = self
.balance_sheet(Parameters(BalanceSheetParams { as_of: None }))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://reports/profit-and-loss" => {
let Json(out) = self
.profit_and_loss(Parameters(ProfitAndLossParams {
start: None,
end: None,
class: None,
}))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://digest" => {
let since = digest_since(query, today());
let Json(out) = self.digest(Parameters(DigestParams { since })).await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
handbook::URI => {
let Json(out) = self.handbook(Parameters(HandbookParams::default())).await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
_ if path.starts_with(&format!("{}/", handbook::URI)) => {
let id = &path[handbook::URI.len() + 1..];
handbook::section(
id,
&self.settings(),
self.currency(),
self.document_line_projects(),
)
.ok_or_else(|| format!("unknown handbook section: {id}"))
}
_ if path.starts_with("books://receipts/") => {
let id: u64 = path
.strip_prefix("books://receipts/")
.unwrap_or("")
.parse()
.map_err(|_| format!("bad receipt resource URI: {uri}"))?;
let Json(out) = self
.get_receipt(Parameters(GetReceiptParams {
attachment_id: id,
view: None,
}))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
_ => Err(format!("unknown resource: {uri}")),
}
}
}
#[tool_handler]
impl ServerHandler for BooksServer {
async fn call_tool(
&self,
request: rmcp::model::CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
use crate::access::{self, Call};
use ledger_store::access::Outcome;
let mut call = Call::new(&self.access, &context, "tool", &request.name);
let outcome_of = |out: &Result<rmcp::model::CallToolResponse, rmcp::ErrorData>| match out {
Ok(rmcp::model::CallToolResponse::Complete(r)) if r.is_error != Some(true) => {
Outcome::Ok
}
_ => Outcome::Error,
};
// A refused call's record carries its arguments' digest too,
// worked out off the async threads.
let refused_digest = |args: Option<serde_json::Map<String, serde_json::Value>>| async move {
tokio::task::spawn_blocking(move || Call::digest_of(args.as_ref()))
.await
.ok()
.flatten()
};
// Closing: refused as a protocol error, so a client knows it
// didn't run (SharedStore INVARIANT 3).
if self.store.closing() {
call.digested(refused_digest(request.arguments.clone()).await);
return self
.refuse(&call, access::CLOSING, Err(not_run_closing()))
.await;
}
if self.store.stopped() {
call.digested(refused_digest(request.arguments.clone()).await);
return self
.refuse(&call, access::STOPPED, Ok(tool_error(STOPPED.to_string())))
.await;
}
let needs = crate::scopes::needs(&request.name, request.arguments.as_ref());
// access.rs INVARIANTS 1, 2.
let refusal = match (call.caller(), needs) {
(None, _) => Some((
access::UNAUTHORIZED,
Ok(tool_error(
"this call came without a token for these books".to_string(),
)),
)),
// A tool no table names isn't run (fail closed): a protocol
// error, as for any unknown tool (mcp-tools.md).
(Some(_), None) => Some((
"unknown_tool",
Err(rmcp::ErrorData::invalid_params(
format!(
"there is no tool {:?}",
request.name.chars().take(64).collect::<String>()
),
None,
)),
)),
(Some(caller), Some(scope)) if !caller.may(scope) => Some((
access::INSUFFICIENT_SCOPE,
Ok(tool_error(format!(
"this agent's token can't do that: {} needs {scope}, and its token ({:?}) has {}. Only the human can give a token more (`wanderbooks token create`).",
request.name,
caller.token.name,
caller
.token
.scopes
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
))),
)),
// A file's bytes or text given as an argument: refused as an
// unknown argument, before it runs (files.rs INVARIANT 5).
_ => files::inline_refusal(&request.name, request.arguments.as_ref()).map(|why| {
(
access::INLINE_FILE,
Err(rmcp::ErrorData::invalid_params(why, None)),
)
}),
};
if let Some((code, answer)) = refusal {
call.digested(refused_digest(request.arguments.clone()).await);
return self.refuse(&call, code, answer).await;
}
let timezone = self.settings().timezone.clone();
let server = self.clone();
// The journal's head once a call that may change the books has
// run, for its record.
// The call's record is written on its own blocking thread, before
// it counts as done: a close waits for the record too (access.rs
// INVARIANT 3).
let running_call = call.clone();
// Kept for the record of a call the server stopped before running.
let request_args = request.arguments.clone();
let result = run_blocking(&self.store, move || {
Box::pin(async move {
WROTE.with(|w| w.borrow_mut().take());
let caller = running_call
.caller()
.map(|c| (c.token.id.clone(), c.token.name.clone()));
CALLER.with(|c| *c.borrow_mut() = caller);
// Counted as running by now (run_blocking): hashed here.
let digest = Call::digest_of(request.arguments.as_ref());
let tcc =
rmcp::handler::server::tool::ToolCallContext::new(&server, request, context);
let router = Self::tool_router();
use futures_util::FutureExt;
let out =
std::panic::AssertUnwindSafe(in_timezone_async(timezone, router.call(tcc)))
.catch_unwind()
.await;
// The head after this call's own last write, if it wrote,
// whatever it answered.
let wrote = WROTE
.with(|w| w.borrow_mut().take())
.map(|(seq, hash)| ledger_store::chain::Head { seq, hash });
let out = match out {
Ok(out) => out,
Err(panic) => {
// It ran and failed part way: recorded as an error
// with what it wrote, while still counted as
// running; then the panic goes on and stops the
// server (SharedStore INVARIANT 1).
let mut c = running_call;
c.digested(digest);
let _ = c.record_blocking(Outcome::Error, wrote);
std::panic::resume_unwind(panic)
}
};
let mut c = running_call;
c.digested(digest);
let recorded = c.record_blocking(outcome_of(&out), wrote);
(out, recorded)
})
})
.await;
match result {
Ok((out, Ok(()))) => redact_refusal(out),
Ok((_, Err(()))) => {
// access.rs INVARIANT 3.
self.store.stop();
Ok(tool_error(crate::access::RAN_NOT_RECORDED.to_string()))
}
// Not run because the books are closing: a protocol error, so
// the client knows (SharedStore INVARIANT 3). (Its arguments
// went with the request: no digest.)
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => {
self.refuse(&call, access::CLOSING, Err(e)).await
}
// Not run: the server had stopped by the time it would have.
Err(e) if e.code.0 == NOT_RUN_STOPPED_CODE => {
call.digested(refused_digest(request_args.clone()).await);
self.refuse(&call, access::STOPPED, Ok(tool_error(STOPPED.to_string())))
.await
}
// It panicked part way (recorded as it went): a plain refusal,
// redacted like every other.
Err(e) => redact_refusal(Ok(tool_error(e.message.to_string()))),
}
}
async fn list_tools(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListToolsResult, rmcp::ErrorData> {
let mut tools = Self::tool_router().list_all();
// A token is shown what it can call (scopes.rs); the list then
// differs by caller, so it may be cached only for this one.
let scopes = crate::access::caller(&context)
.map(|c| c.token.scopes)
.unwrap_or_default();
tools.retain(|t| crate::scopes::shown(&t.name, &scopes));
let hints = context
.protocol_version()
.is_some_and(|v| v >= rmcp::model::ProtocolVersion::V_2026_07_28);
Ok(rmcp::model::ListToolsResult {
result_type: Some(rmcp::model::ResultType::COMPLETE),
tools,
meta: None,
next_cursor: None,
ttl_ms: hints.then_some(0),
cache_scope: hints.then_some(rmcp::model::CacheScope::Private),
})
}
fn get_info(&self) -> ServerConfig {
// A client connecting after a settings change sees the change
// (never waiting for the books here; a request holding them syncs
// them when it locks).
if let Ok(store) = self.store.store.try_lock()
&& let Some(store) = store.as_ref()
{
self.live()
.sync(store.ledger().settings(), self.store.currency.code());
}
ServerConfig::new(crate::opening::capabilities())
// The rules in brief, with the policy these books actually use;
// the rest is in the handbook.
.with_instructions(handbook::connect_text(&self.settings(), self.currency()))
.with_server_info(Implementation::new(
"wanderbooks",
env!("CARGO_PKG_VERSION"),
))
}
async fn list_resources(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, rmcp::ErrorData> {
Ok(ListResourcesResult::with_all_items(resource_list()))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
context: RequestContext<RoleServer>,
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
use crate::access::{self, Call};
use ledger_store::access::Outcome;
// Reading a resource is reading the books: books:read, recorded
// (access.rs).
let call = Call::new(&self.access, &context, "resource", &request.uri);
let result = self.resource(request, &call).await;
{
let outcome = match &result {
Ok(_) => Outcome::Ok,
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => {
Outcome::Refused(access::CLOSING.into())
}
Err(e) if e.message == access::INSUFFICIENT_SCOPE => {
Outcome::Refused(access::INSUFFICIENT_SCOPE.into())
}
Err(e) if e.message == access::UNAUTHORIZED => {
Outcome::Refused(access::UNAUTHORIZED.into())
}
Err(_) => Outcome::Error,
};
if call.record(outcome, None).await.is_err() {
self.store.stop();
// Not run stays so; the words say why.
return Err(match result {
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => rmcp::ErrorData::new(
e.code,
format!("{}; {}", e.message, access::NOT_RECORDED),
None,
),
_ => rmcp::ErrorData::internal_error(access::NOT_RECORDED, None),
});
}
}
result
}
}
impl BooksServer {
/// Record a call refused unrun, then give its answer (access.rs
/// INVARIANT 3).
async fn refuse(
&self,
call: &crate::access::Call,
code: &str,
answer: Result<rmcp::model::CallToolResponse, rmcp::ErrorData>,
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
if call
.record(ledger_store::access::Outcome::Refused(code.into()), None)
.await
.is_err()
{
self.store.stop();
// A call refused as not run stays so (the bridge sends it
// again when the books are back); the words say why.
return match answer {
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => Err(rmcp::ErrorData::new(
e.code,
format!("{}; {}", e.message, crate::access::NOT_RECORDED),
None,
)),
_ => Ok(tool_error(crate::access::NOT_RECORDED.to_string())),
};
}
answer
}
/// A resource's contents, once its caller may read the books.
async fn resource(
&self,
request: ReadResourceRequestParams,
call: &crate::access::Call,
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
match call.caller() {
None => {
return Err(rmcp::ErrorData::invalid_request(
crate::access::UNAUTHORIZED,
None,
));
}
Some(c) if !c.may(ledger_store::auth::Scope::Read) => {
return Err(rmcp::ErrorData::invalid_request(
crate::access::INSUFFICIENT_SCOPE,
None,
));
}
Some(_) => {}
}
let timezone = self.settings().timezone.clone();
let uri = request.uri.clone();
let server = self.clone();
let at = uri.clone();
let text = run_blocking(&self.store, move || {
Box::pin(async move { in_timezone_async(timezone, server.resource_text(&at)).await })
})
.await?;
// A refusal quotes the URI it was given: masked like a tool's.
let text = text.map_err(|e| {
redact_error(rmcp::ErrorData::resource_not_found(
uri.clone(),
Some(serde_json::json!({"reason": e})),
))
})?;
let mime_type = if uri.starts_with(&format!("{}/", handbook::URI)) {
"text/markdown"
} else {
"application/json"
};
Ok(ReadResourceResponse::Complete(ReadResourceResult::new(
vec![ResourceContents::TextResourceContents {
uri,
mime_type: Some(mime_type.to_string()),
text,
meta: None,
}],
)))
}
}
#[cfg(test)]
#[path = "server_tests.rs"]
mod tests;
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use std::str::FromStr;
pub(crate) fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
pub(crate) fn usd(s: &str) -> Money {
Money::new(Decimal::from_str(s).unwrap(), Currency::USD)
}
/// A test's books' folder (`.0`) in a data root of its own, under a
/// home (`.1`) removed when the test ends, pass or panic.
pub(crate) struct TestDir(pub(crate) std::path::PathBuf, pub(crate) std::path::PathBuf);
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.1);
}
}
pub(crate) fn unit_dir(tag: &str) -> TestDir {
let home =
std::env::temp_dir().join(format!("ledger-mcp-unit-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let (root, _) =
ledger_store::root::Root::setup(&home, Some(&home.join("root")), None).unwrap();
TestDir(root.path().join("books").join("acme"), home)
}
/// New books in `dir`, which outlives them.
pub(crate) fn store(dir: &TestDir) -> Store {
Store::create(&dir.0, Currency::USD).unwrap()
}
/// A session's server for the books `store` holds, made as the books
/// server makes every one (Served::open): driven here without a
/// transport.
pub(crate) fn served(store: Store) -> BooksServer {
BooksServer::new(&Served::open(store).expect("the books are served"))
}
pub(crate) fn post(
s: &mut Store,
date: NaiveDate,
dr: AccountId,
cr: AccountId,
amount: &str,
) -> u64 {
let e = s.create_entry(date, "x").unwrap();
s.add_line(e, dr, Side::Debit, usd(amount)).unwrap();
s.add_line(e, cr, Side::Credit, usd(amount)).unwrap();
s.post_entry(e).unwrap();
e
}
/// `store` with `changes` journaled, as `books.set_setting` journals
/// them: a session's server reads its settings only from there.
pub(crate) fn settled(mut store: Store, changes: &[(&str, serde_json::Value)]) -> Store {
let changes: Vec<_> = changes
.iter()
.map(|(key, value)| ledger_core::settings::Change {
key: (*key).to_string(),
value: Some(value.clone()),
})
.collect();
let source = ledger_core::settings::Source {
decided_by: "Morgan Ellis".into(),
decided_on: d(2026, 1, 1),
citation: None,
note: None,
};
store
.change_settings(&changes, &source)
.expect("the settings are taken");
store
}
/// Two members, Ada (with a distributions account) and Bo.
pub(crate) fn members() -> (&'static str, serde_json::Value) {
(
"company.members",
serde_json::json!([
{"name": "Ada", "share": "0.5", "capital_account": "3000",
"distribution_account": "3100"},
{"name": "Bo", "share": "0.5", "capital_account": "3001"},
]),
)
}
}