Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan

This commit is contained in:
swaits committed 2026-10-11 03:01:30 +00:00
1 parent 5baaa12bf7
commit 0e4ec80cd8
1 file changed
+673 -15
+673 -15
View File
@@ -10,7 +10,11 @@ use ledger_core::{
};
use ledger_store::Store;
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::{Implementation, ServerCapabilities, ServerConfig};
use rmcp::model::{
Implementation, ListResourcesResult, ReadResourceRequestParams, ReadResourceResponse,
ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerConfig,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::{ServerHandler, tool, tool_handler, tool_router};
use rust_decimal::Decimal;
use std::sync::{Arc, Mutex};
@@ -39,6 +43,12 @@ impl BooksServer {
}
}
/// Build the server around a shared store (for HTTP: each connection
/// gets a handle to the same store).
pub fn new_shared(store: Arc<Mutex<Store>>, policy: ReceiptPolicy) -> Self {
Self { store, policy }
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Store>, String> {
self.store
.lock()
@@ -407,9 +417,82 @@ impl BooksServer {
}))
}
#[tool(
name = "books.cash_flow",
description = "Cash flow statement (indirect method) for a period: net income, depreciation add-back, working-capital changes, investing (asset purchases, disposal proceeds), financing. The three sections reconcile to the net change in the given cash accounts.",
annotations(read_only_hint = true)
)]
async fn cash_flow(
&self,
params: Parameters<CashFlowParams>,
) -> Result<Json<CashFlowOut>, String> {
let p = params.0;
let store = self.lock()?;
let ledger = store.ledger();
let start = match p.start {
Some(s) => parse_date(&s)?,
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
};
let end = match p.end {
Some(s) => parse_date(&s)?,
None => chrono::Utc::now().date_naive(),
};
// Resolve cash and financing account codes to ids.
let mut cash_accounts = Vec::new();
for code in &p.cash_accounts {
let id = ledger
.chart()
.find_by_code(code)
.map(|a| a.id)
.ok_or_else(|| format!("unknown cash account code: {code}"))?;
cash_accounts.push(id);
}
if cash_accounts.is_empty() {
return Err("at least one cash account code is required".to_string());
}
let mut financing_accounts = Vec::new();
for code in &p.financing_accounts {
let id = ledger
.chart()
.find_by_code(code)
.map(|a| a.id)
.ok_or_else(|| format!("unknown financing account code: {code}"))?;
financing_accounts.push(id);
}
let stmt = ledger_core::cash_flow(
ledger,
&ledger_core::CashFlowParams {
start,
end,
cash_accounts,
financing_accounts,
},
)
.map_err(|e| e.to_string())?;
let line = |l: &ledger_core::CashFlowLine| CashFlowLineOut {
label: l.label.clone(),
amount: money(l.amount),
};
Ok(Json(CashFlowOut {
start: stmt.start.to_string(),
end: stmt.end.to_string(),
net_income: money(stmt.net_income),
depreciation: money(stmt.depreciation),
working_capital: stmt.working_capital.iter().map(line).collect(),
cash_from_operations: money(stmt.cash_from_operations),
investing: stmt.investing.iter().map(line).collect(),
cash_from_investing: money(stmt.cash_from_investing),
financing: stmt.financing.iter().map(line).collect(),
cash_from_financing: money(stmt.cash_from_financing),
net_change_in_cash: money(stmt.net_change_in_cash),
cash_beginning: money(stmt.cash_beginning),
cash_ending: money(stmt.cash_ending),
}))
}
#[tool(
name = "books.draft_entry",
description = "Start a draft journal entry. Drafts are not journaled until posted; add lines with books.add_line."
description = "Start a draft journal entry. Drafts are journaled and survive restarts, but don't affect reports until posted; add lines with books.add_line."
)]
async fn draft_entry(
&self,
@@ -484,8 +567,14 @@ impl BooksServer {
));
}
}
let receipt_required = self.policy.receipt_required(store.ledger(), &entry);
let mut receipt_required = self.policy.receipt_required(store.ledger(), &entry);
let receipts = preview_receipts(&store, p.entry_id)?;
// Anti-splitting: if this entry looks like part of a split group,
// the receipt policy applies to the group total.
let split_group = self.policy.split_group(store.ledger(), p.entry_id, &entry);
if !split_group.is_empty() {
receipt_required = true;
}
let receipt_missing = receipt_required && receipts.is_empty();
let out = PostEntryOut {
status: "preview".to_string(),
@@ -497,17 +586,41 @@ impl BooksServer {
receipts,
receipt_required,
receipt_missing,
split_detected: split_group.clone(),
};
if !p.confirmed.unwrap_or(false) {
return Ok(Json(out));
}
if receipt_missing {
return Err(format!(
"receipt_required: entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting",
let split_note = if split_group.is_empty() {
String::new()
} else {
format!(
" Anti-splitting: this looks like part of a split group with entries {:?}; the policy applies to the group total.",
split_group
)
};
return Err(ReceiptRequiredError {
code: "receipt_required".to_string(),
message: format!(
"entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting.{}",
p.entry_id,
money(total),
self.policy.describe()
));
self.policy.describe(),
split_note
),
entry_id: Some(p.entry_id),
asset_id: None,
debits: total.to_string(),
policy: self.policy.describe(),
remedy_tool: "books.attach_receipt".to_string(),
remedy_hint: format!(
"attach a receipt to entry {} with books.attach_receipt, then retry books.post_entry with confirmed=true",
p.entry_id
),
split_group,
}
.to_tool_error());
}
store.post_entry(p.entry_id).map_err(|e| e.to_string())?;
let mut posted = out;
@@ -873,29 +986,215 @@ impl BooksServer {
}))
}
#[tool(
name = "books.import_bank_csv",
description = "Import a QuickBooks-style bank/credit-card CSV (Date,Description,Amount or Date,Description,Expense,Income). Creates one DRAFT entry per transaction: bank account on one side, categorized offset on the other (via rules or default_offset_code). Review with books.digest, then post."
)]
async fn import_bank_csv(
&self,
params: Parameters<ImportBankCsvParams>,
) -> Result<Json<ImportBankCsvOut>, String> {
let p = params.0;
let txns = ledger_import::parse_bank_csv(&p.csv_text).map_err(|e| e.to_string())?;
let mut store = self.lock()?;
let bank = store
.ledger()
.chart()
.find_by_code(&p.bank_account_code)
.map(|a| (a.id, a.code.clone()))
.ok_or_else(|| format!("unknown bank account code: {}", p.bank_account_code))?;
let default_offset = store
.ledger()
.chart()
.find_by_code(&p.default_offset_code)
.map(|a| (a.id, a.code.clone()))
.ok_or_else(|| format!("unknown offset account code: {}", p.default_offset_code))?;
// Resolve rule account codes upfront.
let mut rules: Vec<(String, (ledger_core::AccountId, String))> = Vec::new();
for (pattern, code) in &p.rules {
let (id, c) = store
.ledger()
.chart()
.find_by_code(code)
.map(|a| (a.id, a.code.clone()))
.ok_or_else(|| format!("unknown rule account code: {code}"))?;
rules.push((pattern.to_lowercase(), (id, c)));
}
let mut imported = Vec::new();
for txn in txns {
let desc_lower = txn.description.to_lowercase();
let (offset_id, offset_code) = rules
.iter()
.find(|(pat, _)| desc_lower.contains(pat))
.map(|(_, v)| v.clone())
.unwrap_or_else(|| default_offset.clone());
let amount = Money::new(txn.amount.abs(), store.ledger().base_currency());
let entry_id = store
.create_entry(txn.date, &format!("Bank import — {}", txn.description))
.map_err(|e| e.to_string())?;
// Money in: debit bank, credit offset. Money out: reverse.
let (bank_side, offset_side) = if txn.amount > Decimal::ZERO {
(Side::Debit, Side::Credit)
} else {
(Side::Credit, Side::Debit)
};
store
.add_line(entry_id, bank.0, bank_side, amount)
.map_err(|e| e.to_string())?;
store
.add_line(entry_id, offset_id, offset_side, amount)
.map_err(|e| e.to_string())?;
imported.push(ImportedTxnOut {
entry_id,
date: txn.date.to_string(),
description: txn.description.clone(),
amount: money(txn.amount),
offset_account: offset_code,
});
}
let count = imported.len();
Ok(Json(ImportBankCsvOut { imported, count }))
}
#[tool(
name = "books.import_chart_csv",
description = "Import a chart of accounts from CSV (header: code,name,type,parent_code). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported."
)]
async fn import_chart_csv(
&self,
params: Parameters<ImportChartCsvParams>,
) -> Result<Json<ImportChartCsvOut>, String> {
let p = params.0;
let rows = ledger_import::parse_chart_csv(&p.csv_text).map_err(|e| e.to_string())?;
let mut store = self.lock()?;
// Validate all rows first: nothing is half-imported.
for row in &rows {
if store.ledger().chart().find_by_code(&row.code).is_some() {
return Err(format!("duplicate account code: {}", row.code));
}
}
let mut created = Vec::new();
for row in rows {
let account_type = match row.account_type.as_str() {
"asset" => AccountType::Asset,
"liability" => AccountType::Liability,
"equity" => AccountType::Equity,
"revenue" => AccountType::Revenue,
"expense" => AccountType::Expense,
_ => return Err(format!("bad account type: {}", row.account_type)),
};
let parent = match &row.parent_code {
Some(code) => Some(
store
.ledger()
.chart()
.find_by_code(code)
.map(|a| a.id)
.ok_or_else(|| format!("unknown parent code: {code}"))?,
),
None => None,
};
store
.add_account(&row.code, &row.name, account_type, parent, false)
.map_err(|e| e.to_string())?;
created.push(row.code.clone());
}
let count = created.len();
Ok(Json(ImportChartCsvOut { created, count }))
}
#[tool(
name = "books.import_local_file",
description = "Import a file from the server's local filesystem into the blob store (receipt or document). Used for backfills: the agent downloads from Google Drive (or elsewhere) to a temp path, then calls this. Path must be absolute."
)]
async fn import_local_file(
&self,
params: Parameters<ImportLocalFileParams>,
) -> Result<Json<ImportLocalFileOut>, String> {
let p = params.0;
let path = std::path::Path::new(&p.path);
if !path.is_absolute() {
return Err("path must be absolute".to_string());
}
let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", p.path))?;
if bytes.len() > 25 * 1024 * 1024 {
return Err("file exceeds 25 MB limit".to_string());
}
let file_name = p.file_name.unwrap_or_else(|| {
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string()
});
let mime_type = mime_guess::from_path(path)
.first_or_octet_stream()
.to_string();
let mut store = self.lock()?;
let (attachment_id, sha256, deduplicated) = match p.category.as_str() {
"receipt" => store
.attach_receipt(p.entry_id, &file_name, &mime_type, &bytes, p.description.as_deref())
.map_err(|e| e.to_string())?,
"document" => {
let cat = p.document_category.as_deref().unwrap_or("other");
store
.file_document(cat, &file_name, &mime_type, &bytes, p.description.as_deref())
.map_err(|e| e.to_string())?
}
_ => return Err("category must be \"receipt\" or \"document\"".to_string()),
};
Ok(Json(ImportLocalFileOut {
attachment_id,
sha256,
deduplicated,
file_name,
}))
}
#[tool(
name = "books.export",
description = "Export a report as CSV or JSON. Reports: journal (every posted entry with lines), trial_balance, profit_and_loss, balance_sheet.",
description = "Export a report. Formats: csv, json, xlsx (base64), iif (journal only, for QuickBooks Desktop). Reports: journal, trial_balance, profit_and_loss, balance_sheet, cash_flow.",
annotations(read_only_hint = true)
)]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
let p = params.0;
let format = p.format.to_lowercase();
if format != "csv" && format != "json" {
if !["csv", "json", "xlsx", "iif"].contains(&format.as_str()) {
return Err(format!(
"bad format {:?}: expected \"csv\" or \"json\"",
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", or \"iif\"",
p.format
));
}
let report = p.report.to_lowercase();
if format == "iif" && report != "journal" {
return Err("iif format is only valid for the journal report".to_string());
}
if format == "xlsx" {
let bytes = self.export_xlsx(&report, &p)?;
return Ok(Json(ExportOut {
format,
report,
content: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes),
content_encoding: Some("base64".to_string()),
}));
}
if format == "iif" {
let content = self.export_iif()?;
return Ok(Json(ExportOut {
format,
report,
content,
content_encoding: None,
}));
}
let content = match report.as_str() {
"journal" => self.export_journal(&format)?,
"trial_balance" => self.export_trial_balance(&format)?,
"profit_and_loss" => self.export_pnl(&format, p.start.as_deref(), p.end.as_deref())?,
"balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?,
"cash_flow" => self.export_cash_flow(&format, &p)?,
_ => {
return Err(format!(
"bad report {:?}: expected journal, trial_balance, profit_and_loss, or balance_sheet",
"bad report {:?}: expected journal, trial_balance, profit_and_loss, balance_sheet, or cash_flow",
p.report
));
}
@@ -904,9 +1203,122 @@ impl BooksServer {
format,
report,
content,
content_encoding: None,
}))
}
/// Render a report to XLSX bytes via ledger-import.
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
use ledger_import::{XlsxReport, report_to_xlsx};
let store = self.lock()?;
let ledger = store.ledger();
// Build owned report data first (all report structs are owned).
enum Owned {
Tb(ledger_core::reports::TrialBalance),
Pnl(ledger_core::reports::ProfitAndLoss, String, String),
Bs(ledger_core::reports::BalanceSheet, String),
Cf(ledger_core::reports::CashFlowStatement),
}
let owned = match report {
"trial_balance" => Owned::Tb(reports::trial_balance(ledger)),
"profit_and_loss" => {
let start = match &p.start {
Some(s) => parse_date(s)?,
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
};
let end = match &p.end {
Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(),
};
Owned::Pnl(
reports::profit_and_loss(ledger).map_err(|e| e.to_string())?,
start.to_string(),
end.to_string(),
)
}
"balance_sheet" => {
let as_of = match &p.as_of {
Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(),
};
// Engine balance_sheet is as-of now; xlsx uses the same.
// (The books.balance_sheet tool does as-of via movement_through.)
Owned::Bs(
reports::balance_sheet(ledger).map_err(|e| e.to_string())?,
as_of.to_string(),
)
}
"cash_flow" => {
let mut cash_accounts = Vec::new();
for code in &p.cash_accounts {
let id = ledger
.chart()
.find_by_code(code)
.map(|a| a.id)
.ok_or_else(|| format!("unknown cash account code: {code}"))?;
cash_accounts.push(id);
}
if cash_accounts.is_empty() {
return Err("cash_accounts is required for cash_flow".to_string());
}
let start = match &p.start {
Some(s) => parse_date(s)?,
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
};
let end = match &p.end {
Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(),
};
Owned::Cf(
ledger_core::cash_flow(
ledger,
&ledger_core::CashFlowParams {
start,
end,
cash_accounts,
financing_accounts: vec![],
},
)
.map_err(|e| e.to_string())?,
)
}
_ => {
return Err(format!(
"bad report {report:?} for xlsx: expected trial_balance, profit_and_loss, balance_sheet, or cash_flow"
));
}
};
let xlsx_report = match &owned {
Owned::Tb(tb) => XlsxReport::TrialBalance(tb),
Owned::Pnl(pl, s, e) => XlsxReport::ProfitAndLoss(pl, s.clone(), e.clone()),
Owned::Bs(bs, a) => XlsxReport::BalanceSheet(bs, a.clone()),
Owned::Cf(cf) => XlsxReport::CashFlow(cf),
};
report_to_xlsx(&xlsx_report).map_err(|e| e.to_string())
}
/// Render the journal as IIF text via ledger-import.
fn export_iif(&self) -> Result<String, String> {
use ledger_import::{IifEntry, journal_to_iif};
let store = self.lock()?;
let ledger = store.ledger();
let account_names = |id: ledger_core::AccountId| {
ledger
.chart()
.get(id)
.map(|a| a.name.clone())
.unwrap_or_default()
};
let entries: Vec<IifEntry<'_>> = ledger
.posted_entries()
.map(|e| IifEntry {
entry: e,
account_names: &account_names,
})
.collect();
Ok(journal_to_iif(&entries))
}
// ------------------------------------------------------------------
// Fixed assets
// ------------------------------------------------------------------
@@ -1173,12 +1585,25 @@ impl BooksServer {
}
let attachment_ids = p.receipt_attachment_ids.unwrap_or_default();
if receipt_required && attachment_ids.is_empty() {
return Err(format!(
"receipt_required: disposing {} moves {} in debits ({}); file the bill of sale with books.file_document, then retry with receipt_attachment_ids",
return Err(ReceiptRequiredError {
code: "receipt_required".to_string(),
message: format!(
"disposing {} moves {} in debits ({}); file the bill of sale with books.file_document, then retry with receipt_attachment_ids",
asset.description,
money(debits),
self.policy.describe()
));
),
entry_id: None,
asset_id: Some(p.asset_id),
debits: debits.to_string(),
policy: self.policy.describe(),
remedy_tool: "books.file_document".to_string(),
remedy_hint:
"file the bill of sale with books.file_document, then retry books.dispose_asset with confirmed=true and receipt_attachment_ids=[<id>]"
.to_string(),
split_group: Vec::new(),
}
.to_tool_error());
}
let entry_id = store
.dispose_asset(
@@ -1380,6 +1805,111 @@ impl BooksServer {
money(current_earnings)
))
}
fn export_cash_flow(&self, format: &str, p: &ExportParams) -> Result<String, String> {
let store = self.lock()?;
let ledger = store.ledger();
let mut cash_accounts = Vec::new();
for code in &p.cash_accounts {
let id = ledger
.chart()
.find_by_code(code)
.map(|a| a.id)
.ok_or_else(|| format!("unknown cash account code: {code}"))?;
cash_accounts.push(id);
}
if cash_accounts.is_empty() {
return Err("cash_accounts is required for cash_flow".to_string());
}
let start = match &p.start {
Some(s) => parse_date(s)?,
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
};
let end = match &p.end {
Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(),
};
let stmt = ledger_core::cash_flow(
ledger,
&ledger_core::CashFlowParams {
start,
end,
cash_accounts,
financing_accounts: vec![],
},
)
.map_err(|e| e.to_string())?;
if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({
"start": stmt.start.to_string(), "end": stmt.end.to_string(),
"net_income": money(stmt.net_income),
"depreciation": money(stmt.depreciation),
"working_capital": stmt.working_capital.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_operations": money(stmt.cash_from_operations),
"investing": stmt.investing.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_investing": money(stmt.cash_from_investing),
"financing": stmt.financing.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_financing": money(stmt.cash_from_financing),
"net_change_in_cash": money(stmt.net_change_in_cash),
"cash_beginning": money(stmt.cash_beginning),
"cash_ending": money(stmt.cash_ending),
}))
.map_err(|e| e.to_string());
}
let mut csv = String::from("section,label,amount\n");
csv.push_str(&format!(
"operating,Net income,{}\n",
money(stmt.net_income)
));
csv.push_str(&format!(
"operating,Depreciation,{}\n",
money(stmt.depreciation)
));
for l in &stmt.working_capital {
csv.push_str(&format!(
"operating,{},{}\n",
csv_escape(&l.label),
money(l.amount)
));
}
csv.push_str(&format!(
"operating,Cash from operations,{}\n",
money(stmt.cash_from_operations)
));
for l in &stmt.investing {
csv.push_str(&format!(
"investing,{},{}\n",
csv_escape(&l.label),
money(l.amount)
));
}
csv.push_str(&format!(
"investing,Cash from investing,{}\n",
money(stmt.cash_from_investing)
));
for l in &stmt.financing {
csv.push_str(&format!(
"financing,{},{}\n",
csv_escape(&l.label),
money(l.amount)
));
}
csv.push_str(&format!(
"financing,Cash from financing,{}\n",
money(stmt.cash_from_financing)
));
csv.push_str(&format!(
"reconciliation,Net change in cash,{}\n",
money(stmt.net_change_in_cash)
));
Ok(csv)
}
}
fn csv_escape(s: &str) -> String {
@@ -1394,14 +1924,142 @@ fn csv_escape(s: &str) -> String {
// 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://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().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,
}))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://digest" => {
let since = query
.split('&')
.find_map(|kv| kv.strip_prefix("since="))
.unwrap_or("")
.to_string();
let Json(out) = self.digest(Parameters(DigestParams { since })).await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
_ 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 }))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
_ => Err(format!("unknown resource: {uri}")),
}
}
}
#[tool_handler]
impl ServerHandler for BooksServer {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
ServerConfig::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build(),
)
.with_instructions(INSTRUCTIONS)
.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> {
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://chart",
"chart",
"Chart of accounts",
"All accounts with codes, types, and hierarchy.",
),
mk(
"books://reports/trial-balance",
"trial-balance",
"Trial balance",
"Current trial balance, debits equal credits.",
),
mk(
"books://reports/balance-sheet",
"balance-sheet",
"Balance sheet",
"Assets, liabilities, equity.",
),
mk(
"books://reports/profit-and-loss",
"profit-and-loss",
"Profit and loss",
"Revenue, expenses, net income.",
),
mk(
"books://digest",
"digest",
"Activity digest",
"What changed in the books. Optional ?since=YYYY-MM-DD query.",
),
];
Ok(ListResourcesResult::with_all_items(resources))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
let uri = request.uri.clone();
let text = self.resource_text(&uri).await.map_err(|e| {
rmcp::ErrorData::resource_not_found(uri.clone(), Some(serde_json::json!({"reason": e})))
})?;
Ok(ReadResourceResponse::Complete(ReadResourceResult::new(
vec![ResourceContents::TextResourceContents {
uri,
mime_type: Some("application/json".to_string()),
text,
meta: None,
}],
)))
}
}