P2 slice 3: opening and closing the books (plan 3.9, D-21, D-23)
One wanderbooks executable. The books are open while the viewer, an agent session or a terminal holds them: a host process takes leases on a socket, starts the server (the hidden mode `wanderbooks server`) with a pipe on its input, and closes it 10 s after the last lease ends. No background service. One host even in a race (run/host.lock). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
168e5c9846
commit
a298d154b6
31 files changed
+11937
-112
No files matched your search
+226
-48
@@ -50,7 +50,7 @@ use ledger_store::Store;
|
||||
use rmcp::handler::server::wrapper::{Json, Parameters};
|
||||
use rmcp::model::{
|
||||
Implementation, ListResourcesResult, ReadResourceRequestParams, ReadResourceResponse,
|
||||
ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerConfig,
|
||||
ReadResourceResult, Resource, ResourceContents, ServerConfig,
|
||||
};
|
||||
use rmcp::service::{RequestContext, RoleServer};
|
||||
use rmcp::{ServerHandler, tool, tool_handler, tool_router};
|
||||
@@ -70,6 +70,53 @@ pub struct BooksServer {
|
||||
store: Arc<SharedStore>,
|
||||
settings: Arc<Mutex<Live>>,
|
||||
import_roots: Arc<ImportRoots>,
|
||||
layout: Arc<Layout>,
|
||||
}
|
||||
|
||||
/// Where a book's own files live around its data dir.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Layout {
|
||||
/// A books dir, as before P2: the data dir is `books-data/` in a
|
||||
/// project folder (the books dir), which holds `vault/`, the settings
|
||||
/// file and perhaps git; the vault's key lives outside it.
|
||||
BooksDir,
|
||||
/// A book in the data root (plan 3.4): the data dir is `books/<id>/`,
|
||||
/// holding its own `vault/` and its v1 key in `keys/legacy.key`. The
|
||||
/// root (the path given) plays the books dir's part: nothing in it is
|
||||
/// ever read or written through a path a tool names.
|
||||
Root(std::path::PathBuf),
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// What a tool never reads or writes through a path: the books dir,
|
||||
/// or the whole data root.
|
||||
fn books_dir(&self, store: &Store) -> std::path::PathBuf {
|
||||
match self {
|
||||
Layout::BooksDir => books_dir(store),
|
||||
Layout::Root(root) => root.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The TIN vault's folder.
|
||||
fn vault_dir(&self, store: &Store) -> std::path::PathBuf {
|
||||
match self {
|
||||
Layout::BooksDir => books_dir(store).join("vault"),
|
||||
Layout::Root(_) => store.data_dir().join("vault"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The vault's key file: in the data root always the book's own
|
||||
/// `keys/legacy.key`, whatever a setting says; in a books dir the one
|
||||
/// in force (`[vault] key_file`, else the default outside the books).
|
||||
fn key_file(&self, store: &Store, settings: &Settings) -> std::path::PathBuf {
|
||||
match self {
|
||||
Layout::BooksDir => settings.key_file(),
|
||||
Layout::Root(_) => store
|
||||
.data_dir()
|
||||
.join(ledger_store::root::KEYS_DIR)
|
||||
.join(ledger_store::root::LEGACY_KEY_FILE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one store every session shares, and whether it may still be used.
|
||||
@@ -86,10 +133,20 @@ pub struct BooksServer {
|
||||
/// 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 {
|
||||
store: tokio::sync::Mutex<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,
|
||||
@@ -102,9 +159,31 @@ impl SharedStore {
|
||||
currency: store.base_currency(),
|
||||
store: tokio::sync::Mutex::new(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)
|
||||
}
|
||||
@@ -179,7 +258,15 @@ where
|
||||
if shared.stopped() {
|
||||
return Err(rmcp::ErrorData::internal_error(STOPPED, None));
|
||||
}
|
||||
// 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>) {
|
||||
@@ -205,6 +292,43 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub use crate::settings::CashRoles;
|
||||
|
||||
@@ -403,9 +527,17 @@ impl BooksServer {
|
||||
..Settings::default()
|
||||
}))),
|
||||
import_roots: Arc::new(ImportRoots::defaults()),
|
||||
layout: Arc::new(Layout::BooksDir),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a book kept in the data root (`Layout::Root`) rather than
|
||||
/// in a books dir.
|
||||
pub fn with_layout(mut self, layout: Layout) -> Self {
|
||||
self.layout = Arc::new(layout);
|
||||
self
|
||||
}
|
||||
|
||||
/// The folders `books.import_local_file` may read from (local_files.rs).
|
||||
pub fn with_import_roots(mut self, roots: ImportRoots) -> Self {
|
||||
self.import_roots = Arc::new(roots);
|
||||
@@ -727,9 +859,10 @@ impl BooksServer {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The TIN vault's directory: `vault/` in the books dir (git-ignored).
|
||||
fn vault_dir(store: &Store) -> std::path::PathBuf {
|
||||
books_dir(store).join("vault")
|
||||
/// The TIN vault's directory: `vault/` in the books dir (git-ignored),
|
||||
/// or in the book's own folder in the data root.
|
||||
fn vault_dir(&self, store: &Store) -> std::path::PathBuf {
|
||||
self.layout.vault_dir(store)
|
||||
}
|
||||
|
||||
/// What is never read through a path, even inside an import root
|
||||
@@ -739,7 +872,7 @@ impl BooksServer {
|
||||
/// the rest of the books dir.
|
||||
fn protected(&self, store: &Store) -> Protected {
|
||||
Protected::of(
|
||||
&places(store, &self.settings()),
|
||||
&places(store, &self.settings(), &self.layout),
|
||||
&self.import_roots.usable(),
|
||||
)
|
||||
}
|
||||
@@ -757,7 +890,7 @@ impl BooksServer {
|
||||
file_name: &str,
|
||||
) -> Result<(), String> {
|
||||
tin_files::refuse(
|
||||
&Self::vault_dir(store),
|
||||
&self.vault_dir(store),
|
||||
bytes,
|
||||
mime_type,
|
||||
file_name,
|
||||
@@ -809,7 +942,7 @@ impl BooksServer {
|
||||
/// the books (outside_books) and outside every import root, so it can
|
||||
/// never be filed back in as a receipt and read out again.
|
||||
fn tin_file_dest(&self, store: &Store, path: &str) -> Result<std::path::PathBuf, String> {
|
||||
let dest = outside_books(store, path)?;
|
||||
let dest = outside_books(&self.layout.books_dir(store), path)?;
|
||||
self.outside_import_roots(&dest)?;
|
||||
Ok(dest)
|
||||
}
|
||||
@@ -854,8 +987,17 @@ impl BooksServer {
|
||||
/// 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(store: &Store, bytes: &[u8]) -> Result<(), String> {
|
||||
tin_files::record(&Self::vault_dir(store), bytes)
|
||||
fn wrote_tins(&self, store: &Store, bytes: &[u8]) -> Result<(), String> {
|
||||
// The record goes in the vault's folder: in the data root, made
|
||||
// private first, as the vault itself makes it.
|
||||
if let Layout::Root(_) = *self.layout {
|
||||
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}"))?;
|
||||
}
|
||||
tin_files::record(&self.vault_dir(store), bytes)
|
||||
}
|
||||
|
||||
/// Open the TIN vault with the key file in force (`[vault] key_file`,
|
||||
@@ -865,13 +1007,23 @@ impl BooksServer {
|
||||
/// records it unlocks. Storing into books kept in git is refused
|
||||
/// unless their `.gitignore` lists `vault/`.
|
||||
fn open_vault(&self, store: &Store, create: bool) -> Result<ledger_vault::Vault, String> {
|
||||
let key = self.settings().key_file();
|
||||
if is_inside(&books_dir(store), &key) {
|
||||
let key = self.layout.key_file(store, &self.settings());
|
||||
// In the data root the key is the book's own `keys/legacy.key`;
|
||||
// its folder and the vault's are made private when a TIN is first
|
||||
// stored.
|
||||
if let Layout::Root(_) = *self.layout {
|
||||
if create {
|
||||
for folder in [ledger_store::root::KEYS_DIR, "vault"] {
|
||||
ledger_store::root::private_book_folder(store.data_dir(), folder)
|
||||
.map_err(|e| format!("the TIN vault can't store anything yet: {e}"))?;
|
||||
}
|
||||
}
|
||||
} else if is_inside(&books_dir(store), &key) {
|
||||
return Err(KEY_IN_BOOKS.to_string());
|
||||
}
|
||||
// Writing: a git repository must ignore the vault, or its records
|
||||
// would be committed (and could never be purged).
|
||||
let books = books_dir(store);
|
||||
let books = self.layout.books_dir(store);
|
||||
if create && books.join(".git").exists() {
|
||||
let ignored = std::fs::read_to_string(books.join(".gitignore"))
|
||||
.unwrap_or_default()
|
||||
@@ -882,7 +1034,7 @@ impl BooksServer {
|
||||
}
|
||||
}
|
||||
// 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::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, or set [vault] key_file in the settings)".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()),
|
||||
@@ -1732,13 +1884,23 @@ fn known_tin_last4(store: &Store) -> Vec<u32> {
|
||||
/// Where these books keep what an import must never read
|
||||
/// (local_files.rs): the data dir, the books dir, the vault, the key file
|
||||
/// in force and the default one, and the home folder.
|
||||
fn places(store: &Store, settings: &Settings) -> Places {
|
||||
fn places(store: &Store, settings: &Settings, layout: &Layout) -> Places {
|
||||
Places {
|
||||
data_dir: store.data_dir().to_path_buf(),
|
||||
books_dir: books_dir(store),
|
||||
vault_dir: BooksServer::vault_dir(store),
|
||||
key_files: vec![settings.key_file(), ledger_vault::default_key_file()],
|
||||
home: std::env::var_os("HOME").map(std::path::PathBuf::from),
|
||||
books_dir: layout.books_dir(store),
|
||||
vault_dir: layout.vault_dir(store),
|
||||
key_files: vec![
|
||||
layout.key_file(store, settings),
|
||||
settings.key_file(),
|
||||
ledger_vault::default_key_file(),
|
||||
],
|
||||
// In the data root the home comes from the account's record, as
|
||||
// the root's own does; never only from the environment.
|
||||
home: match layout {
|
||||
Layout::Root(_) => ledger_store::root::account_home().ok(),
|
||||
Layout::BooksDir => None,
|
||||
}
|
||||
.or_else(|| std::env::var_os("HOME").map(std::path::PathBuf::from)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1750,9 +1912,10 @@ fn places(store: &Store, settings: &Settings) -> Places {
|
||||
pub fn guard_import_roots(
|
||||
store: &Store,
|
||||
settings: &Settings,
|
||||
layout: &Layout,
|
||||
roots: ImportRoots,
|
||||
) -> Result<ImportRoots, String> {
|
||||
let places = places(store, settings);
|
||||
let places = places(store, settings, layout);
|
||||
let explicit = roots.explicit();
|
||||
let (roots, refused) = roots.guard(places.home.as_deref(), &places.keep_out());
|
||||
if let Some((root, why)) = refused.first() {
|
||||
@@ -1775,12 +1938,12 @@ fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool {
|
||||
|
||||
/// Where a tool may write a file holding a full TIN: a new file, in a
|
||||
/// folder that exists, outside the books dir (which git commits).
|
||||
fn outside_books(store: &Store, path: &str) -> Result<std::path::PathBuf, String> {
|
||||
fn outside_books(books: &std::path::Path, path: &str) -> Result<std::path::PathBuf, String> {
|
||||
let p = std::path::PathBuf::from(path.trim());
|
||||
if path.trim().is_empty() {
|
||||
return Err("give a path to write to".into());
|
||||
}
|
||||
if is_inside(&books_dir(store), &p) {
|
||||
if is_inside(books, &p) {
|
||||
return Err(INSIDE_BOOKS.to_string());
|
||||
}
|
||||
match p.parent() {
|
||||
@@ -1804,7 +1967,7 @@ const EXISTS: &str = "a file is there already: name a new file";
|
||||
/// folder. A new file, in a folder that exists; with TINs, outside the
|
||||
/// books dir (which git commits, `exports/` included).
|
||||
fn package_dest(
|
||||
store: &Store,
|
||||
books: &std::path::Path,
|
||||
to: &str,
|
||||
file_name: &str,
|
||||
tins: bool,
|
||||
@@ -1827,7 +1990,7 @@ fn package_dest(
|
||||
));
|
||||
};
|
||||
if tins {
|
||||
return outside_books(store, &dest.to_string_lossy()).map_err(|e| {
|
||||
return outside_books(books, &dest.to_string_lossy()).map_err(|e| {
|
||||
format!("{e} (the CPA package holds full TINs; save it outside the books)")
|
||||
});
|
||||
}
|
||||
@@ -4880,7 +5043,7 @@ impl BooksServer {
|
||||
let dest = if p.include_tins {
|
||||
self.tin_file_dest(&store, path)?
|
||||
} else {
|
||||
outside_books(&store, path)?
|
||||
outside_books(&self.layout.books_dir(&store), path)?
|
||||
};
|
||||
let mut csv =
|
||||
String::from("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4");
|
||||
@@ -4907,7 +5070,7 @@ impl BooksServer {
|
||||
csv.push('\n');
|
||||
}
|
||||
if p.include_tins {
|
||||
Self::wrote_tins(&store, csv.as_bytes())?;
|
||||
self.wrote_tins(&store, csv.as_bytes())?;
|
||||
}
|
||||
write_private_new(&dest, csv.as_bytes())?;
|
||||
Some(dest.display().to_string())
|
||||
@@ -6967,7 +7130,7 @@ impl BooksServer {
|
||||
.as_ref()
|
||||
.is_some_and(|t| party_tin.as_ref().is_some_and(|pt| pt.last4 != t.last4()));
|
||||
let mut tin_facts: Option<ledger_core::TinFacts> = None;
|
||||
let vault_dir = Self::vault_dir(&store);
|
||||
let vault_dir = self.vault_dir(&store);
|
||||
let (held_tin, held_w9) =
|
||||
ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?;
|
||||
let facts = match facts {
|
||||
@@ -7028,7 +7191,7 @@ impl BooksServer {
|
||||
// never filed into the books (tin_files.rs INVARIANT
|
||||
// 1). Only now: every check has passed.
|
||||
if let Some(w) = &w9_file {
|
||||
Self::wrote_tins(&store, &w.bytes)?;
|
||||
self.wrote_tins(&store, &w.bytes)?;
|
||||
}
|
||||
let tin_hash = match &tin {
|
||||
Some(t) => Some(vault.put_tin(id, t).map_err(|e| e.to_string())?),
|
||||
@@ -7044,10 +7207,14 @@ impl BooksServer {
|
||||
None => w9_hash,
|
||||
}
|
||||
} else if held_tin {
|
||||
ledger_vault::Vault::open(&vault_dir, &self.settings().key_file(), false)
|
||||
.ok()
|
||||
.and_then(|v| v.record_hash(id).ok().flatten())
|
||||
.or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone()))
|
||||
ledger_vault::Vault::open(
|
||||
&vault_dir,
|
||||
&self.layout.key_file(&store, &self.settings()),
|
||||
false,
|
||||
)
|
||||
.ok()
|
||||
.and_then(|v| v.record_hash(id).ok().flatten())
|
||||
.or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone()))
|
||||
} else if held_w9 {
|
||||
existing.as_ref().and_then(|e| e.vault_record.clone())
|
||||
} else {
|
||||
@@ -7215,7 +7382,7 @@ impl BooksServer {
|
||||
"the TIN vault holds no W-9 file for party {id}: store one with books.set_w9 w9_file_path"
|
||||
)
|
||||
})?;
|
||||
Self::wrote_tins(&store, &file.bytes)?;
|
||||
self.wrote_tins(&store, &file.bytes)?;
|
||||
write_private_new(&dest, &file.bytes)?;
|
||||
Ok(Json(GetW9Out {
|
||||
party_id: id,
|
||||
@@ -7270,7 +7437,7 @@ impl BooksServer {
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
let party = store.ledger().get_party(id).expect("resolved").clone();
|
||||
let vault_dir = Self::vault_dir(&store);
|
||||
let vault_dir = self.vault_dir(&store);
|
||||
let (held_tin, held_w9) =
|
||||
ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?;
|
||||
let recorded = party.w9.as_ref().is_some_and(|f| f.vault_record.is_some())
|
||||
@@ -8279,7 +8446,15 @@ impl BooksServer {
|
||||
let dest = p
|
||||
.save_to
|
||||
.as_deref()
|
||||
.map(|to| package_dest(&store, to, &file_name, p.include_tins))
|
||||
.map(|to| {
|
||||
package_dest(
|
||||
&self.layout.books_dir(&store),
|
||||
to,
|
||||
&file_name,
|
||||
// In the data root nothing is ever written, TINs or not.
|
||||
p.include_tins || matches!(*self.layout, Layout::Root(_)),
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
if p.include_tins
|
||||
&& let Some(d) = &dest
|
||||
@@ -8653,7 +8828,7 @@ impl BooksServer {
|
||||
files.push(k1);
|
||||
}
|
||||
let seq = store.journal_seq();
|
||||
let vault_dir = Self::vault_dir(&store);
|
||||
let vault_dir = self.vault_dir(&store);
|
||||
drop(store);
|
||||
files.push((
|
||||
"general-journal.iif".into(),
|
||||
@@ -9385,6 +9560,11 @@ impl ServerHandler for BooksServer {
|
||||
request: rmcp::model::CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
|
||||
// Closing: refused as a protocol error, so a client knows it
|
||||
// didn't run (SharedStore INVARIANT 3).
|
||||
if self.store.closing() {
|
||||
return Err(not_run_closing());
|
||||
}
|
||||
// Pick up an edited settings file before the tool runs.
|
||||
self.live().refresh();
|
||||
let timezone = self.settings().timezone.clone();
|
||||
@@ -9402,6 +9582,9 @@ impl ServerHandler for BooksServer {
|
||||
// plain refusal, redacted like every other.
|
||||
let result = match result {
|
||||
Ok(out) => out,
|
||||
// Not run because the books are closing: a protocol error, so
|
||||
// the client knows (SharedStore INVARIANT 3).
|
||||
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => return Err(e),
|
||||
Err(e) => Ok(rmcp::model::CallToolResponse::Complete(
|
||||
rmcp::model::CallToolResult::error(vec![rmcp::model::ContentBlock::text(
|
||||
e.message.to_string(),
|
||||
@@ -9421,19 +9604,14 @@ impl ServerHandler for BooksServer {
|
||||
.sync(store.ledger().settings(), self.store.currency.code());
|
||||
}
|
||||
self.live().refresh();
|
||||
ServerConfig::new(
|
||||
ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.enable_resources()
|
||||
.build(),
|
||||
)
|
||||
// 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"),
|
||||
))
|
||||
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(
|
||||
|
||||
Reference in new issue
Block a user