feat(mcp): books.start v2 and the agent's self-report (P5)

Agent reason: the first call of a session should say who is asking, what is
waiting and what to do, in under 8 KiB, from the same lists the viewer
draws. books.start now carries book.id and book.url (the book's path on the
server, never a path on a disk), you {principal, kind, scopes, client_id},
drafts {building, awaiting_evidence}, reconciliation {tracked,
never_reconciled, behind}, backup {last_at, through_seq, stale}, the
prompts, and needs_attention (the twelve most urgent items, problems first,
with every item counted by kind), beside next_steps now in the envelope's
shape (tool or prompt, why, required; attention problems are required). A
new book is told to run the onboard prompt first, then to record the
company's facts and set up the chart; an active one is offered a draft
entry. The tool carries _meta anthropic/alwaysLoad, so a client that defers
tools loads it up front. The size fit shortens attention items and waiting
drafts after the convention summaries and before the lists, as before.

agent {model, reasoning_effort, harness} is accepted and written to the
call's access record (the access log shows it as agent), self-reported and
never checked, and used for nothing else: not in the answer, not in any
decision. Who asked is the request's alone: it is cleared when the request
ends, so a later resource read on the same thread cannot see it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 15:48:27 -06:00
1 parent 996c5f40f4
commit 453abb62ea
7 files changed
+459 -25

No files matched your search

+13
View File
@@ -86,6 +86,7 @@ pub(crate) struct Call {
action: &'static str,
subject: String,
args_digest: Option<String>,
agent: Option<ledger_store::access::Agent>,
}
/// Why a call wasn't run, for its record and its answer.
@@ -124,6 +125,7 @@ impl Call {
action,
subject: subject.to_string(),
args_digest: None,
agent: None,
}
}
@@ -140,6 +142,16 @@ impl Call {
self.args_digest = args_digest;
}
/// This call, with what the agent said about itself (`books.start`).
pub(crate) fn with_agent(&mut self, agent: Option<ledger_store::access::Agent>) {
self.agent = agent;
}
/// The client program's name, as it named itself at `initialize`.
pub(crate) fn client_name(&self) -> Option<String> {
self.client.as_ref().map(|c| c.name.clone())
}
/// The call's caller, if the door named one.
pub(crate) fn caller(&self) -> Option<&Caller> {
self.caller.as_ref()
@@ -172,6 +184,7 @@ impl Call {
args_digest: self.args_digest.clone(),
result,
head_after,
agent: self.agent.clone(),
};
match self.log.record(&access) {
Ok(_) => Ok(()),
+3
View File
@@ -1206,6 +1206,7 @@ async fn refused_at_door(
args_digest: None,
result: Outcome::Refused(code.into()),
head_after: None,
agent: None,
};
let written = tokio::task::spawn_blocking(move || access.record(&record)).await;
if !matches!(written, Ok(Ok(_))) {
@@ -1435,6 +1436,7 @@ async fn upload(
args_digest: None,
result: outcome.clone(),
head_after: None,
agent: None,
};
let recorded = access.record(&record).is_ok();
if !recorded && received.is_ok() {
@@ -1609,6 +1611,7 @@ async fn fetch(
Err((_, code, _)) => Outcome::Refused((*code).into()),
},
head_after: None,
agent: None,
};
let recorded = access.record(&record).is_ok();
(found, recorded)
+26 -1
View File
@@ -1381,6 +1381,13 @@ thread_local! {
/// 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) };
/// Who the request running on this thread is, as `books.start` shows
/// it: `{principal, kind, scopes, client_id}`. The token's own facts
/// and the client's name; set at the start of the request.
static YOU: std::cell::RefCell<Option<serde_json::Value>> = const { std::cell::RefCell::new(None) };
/// What the agent said about itself in a `books.start` this request is
/// running (`agent`), for the call's access record.
static AGENT_NOTE: std::cell::RefCell<Option<ledger_store::access::Agent>> = 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.
@@ -1832,6 +1839,17 @@ impl ServerHandler for BooksServer {
.caller()
.map(|c| (c.token.id.clone(), c.token.name.clone()));
CALLER.with(|c| *c.borrow_mut() = caller);
AGENT_NOTE.with(|a| a.borrow_mut().take());
YOU.with(|y| {
*y.borrow_mut() = running_call.caller().map(|c| {
serde_json::json!({
"principal": c.token.name,
"kind": "agent",
"scopes": c.token.scopes.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
"client_id": running_call.client_name(),
})
})
});
// Counted as running by now (run_blocking): hashed here.
let digest = Call::digest_of(request.arguments.as_ref());
let tcc =
@@ -1862,8 +1880,12 @@ impl ServerHandler for BooksServer {
};
// The result envelope (next_steps.rs INVARIANT 1).
let out = next_steps::apply(&server, &tool_name, envelope_args.as_ref(), out);
// Who asked is the request's alone: a later request on this
// thread (a resource read) must not see it.
YOU.with(|y| y.borrow_mut().take());
let mut c = running_call;
c.digested(digest);
c.with_agent(AGENT_NOTE.with(|a| a.borrow_mut().take()));
let recorded = c.record_blocking(outcome_of(&out), wrote);
(out, recorded)
})
@@ -2074,7 +2096,10 @@ impl BooksServer {
let server = self.clone();
let at = uri.clone();
let body = run_blocking(&self.store, move || {
Box::pin(async move { in_timezone_async(timezone, server.resource_body(&at)).await })
Box::pin(async move {
YOU.with(|y| y.borrow_mut().take());
in_timezone_async(timezone, server.resource_body(&at)).await
})
})
.await?;
// A refusal quotes the URI it was given: masked like a tool's.
+14
View File
@@ -9,6 +9,20 @@
use super::*;
use crate::server::test_support::*;
/// Plan 4.2 (P5 step 2): `books.start` is loaded up front by clients that
/// defer tools, and no other tool asks for that.
#[test]
fn books_start_is_always_loaded() {
for tool in BooksServer::tool_router().list_all() {
let always = tool
.meta
.as_ref()
.and_then(|m| m.0.get("anthropic/alwaysLoad"))
.and_then(serde_json::Value::as_bool);
assert_eq!(always == Some(true), tool.name == "books.start", "{}", tool.name);
}
}
/// An upload's limit, 25 MiB exactly (plan 4.7); nothing comes inline
/// (D-46).
#[test]
+344 -13
View File
@@ -94,11 +94,35 @@ pub struct StartParams {
/// (shapes next_steps). Default: daily.
#[serde(default)]
pub focus: Option<String>,
/// What the agent says about itself: its model, reasoning effort and
/// harness. Self-reported and never checked; written to the access
/// log beside the call and used for nothing else.
#[serde(default)]
pub agent: Option<AgentIn>,
}
/// What an agent says about itself (`books.start`'s `agent`).
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct AgentIn {
/// The model's name ("claude-opus-4").
#[serde(default)]
pub model: Option<String>,
/// The reasoning effort it runs at ("high").
#[serde(default)]
pub reasoning_effort: Option<String>,
/// The program driving it ("claude-code").
#[serde(default)]
pub harness: Option<String>,
}
/// The books, in brief.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartBookOut {
/// The books' id on this server.
pub id: String,
/// Where these books answer on this server (`/b/ID/mcp`): never a
/// path on any disk.
pub url: String,
/// The company's name.
pub name: String,
/// Its time zone (IANA), or null: the computer's.
@@ -222,12 +246,115 @@ pub struct FormatUpgradeOut {
#[derive(Debug, Serialize, JsonSchema)]
pub struct NextStepOut {
/// The tool to call.
pub tool: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
/// Or the prompt to run.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
/// Its arguments, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<Value>,
/// Why.
pub why: String,
/// Whether the work is not done without it.
pub required: bool,
}
/// Who is asking, as the books know them (the token's own facts).
#[derive(Debug, Serialize, JsonSchema)]
pub struct YouOut {
/// The token's name.
pub principal: String,
/// Always `agent` here.
pub kind: String,
/// What the token may do: books:read, books:write, books:post,
/// books:admin.
pub scopes: Vec<String>,
/// The client program, as it named itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
}
/// A draft waiting on its evidence.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartDraftOut {
/// The draft entry.
pub entry_id: u64,
/// Its date.
pub date: String,
/// Its total debits.
pub amount: String,
/// What it waits for.
pub note: String,
}
/// The open drafts.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartDraftsOut {
/// Drafts being built.
pub building: u32,
/// Drafts finished and waiting for their documents (books.list_drafts
/// has every one).
pub awaiting_evidence: Vec<StartDraftOut>,
}
/// Where reconciliation stands.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartReconciliationOut {
/// The accounts reconciled to statements (codes).
pub tracked: Vec<String>,
/// Of those, never reconciled.
pub never_reconciled: Vec<String>,
/// Of those, reconciled but behind.
pub behind: Vec<String>,
}
/// Where the books' backups stand.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartBackupOut {
/// When the latest backup took (UTC), if any did.
pub last_at: Option<String>,
/// The journal seq the latest holds.
pub through_seq: Option<u64>,
/// Whether one is stale or failing, or none is set up.
pub stale: bool,
}
/// A prompt, in brief.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartPromptOut {
/// Its name.
pub name: String,
/// The arguments it takes.
pub args: Vec<String>,
}
/// One thing the books need.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AttentionBriefOut {
/// What it is, by kind and subject.
pub id: String,
/// problem, todo or info.
pub severity: String,
/// Its kind (`missing_receipt`, `reconcile`, ...).
pub kind: String,
/// What is wrong, in a line.
pub title: String,
/// The tool that starts the fix, if there is one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
/// Its arguments, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<Value>,
}
/// What needs attention: the most urgent, and how many of each kind.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NeedsAttentionOut {
/// The most urgent (problems first), at most 12.
pub items: Vec<AttentionBriefOut>,
/// Every item by kind.
pub counts: std::collections::BTreeMap<String, u32>,
}
/// Result of `books.start`.
@@ -264,6 +391,19 @@ pub struct StartOut {
/// the owner gives the go-ahead. No tool can: tell the human.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format_upgrade: Option<FormatUpgradeOut>,
/// Who is asking.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub you: Option<YouOut>,
/// The drafts open.
pub drafts: StartDraftsOut,
/// Where reconciliation stands.
pub reconciliation: StartReconciliationOut,
/// Where the backups stand.
pub backup: StartBackupOut,
/// The prompts: whole procedures (`prompts/get`).
pub prompts: Vec<StartPromptOut>,
/// What needs attention, most urgent first.
pub needs_attention: NeedsAttentionOut,
/// What to do next, in order.
pub next_steps: Vec<NextStepOut>,
/// How the books are kept from agents: "same_user (advisory)" locally
@@ -718,6 +858,15 @@ fn open_item_out(i: &ledger_core::conventions::OpenItem) -> OpenItemOut {
}
}
/// `books.start`'s `_meta`: a client that defers tools (tool search) loads
/// this one up front, as the connect text says to call it first.
fn always_load() -> rmcp::model::MetaObject {
let mut meta = rmcp::model::MetaObject::new();
meta.0
.insert("anthropic/alwaysLoad".into(), serde_json::json!(true));
meta
}
impl BooksServer {
/// The books' `books.start` (INVARIANT 4).
fn start_out(&self, store: &Store, focus: Option<&str>) -> StartOut {
@@ -784,9 +933,11 @@ impl BooksServer {
projects.truncate(START_PROJECTS);
let focus = focus.unwrap_or("daily");
let step = |tool: &str, args: Option<serde_json::Value>, why: &str| NextStepOut {
tool: tool.into(),
tool: Some(tool.into()),
prompt: None,
args,
why: why.into(),
required: false,
};
// An attention title can carry a free-text description; keep the
// step short so books.start stays within START_MAX (INVARIANT 4).
@@ -813,10 +964,27 @@ impl BooksServer {
));
}
if onboarding || focus == "onboarding" {
next_steps.push(NextStepOut {
tool: None,
prompt: Some("onboard".into()),
args: None,
why: "New books: run the onboarding procedure with the human.".into(),
required: true,
});
next_steps.push(step(
"books.set_setting",
None,
"Record the company's facts the human gives (name, entity type, fiscal year, time zone); each change is a preview first.",
));
next_steps.push(step(
"books.chart_template",
None,
"Set up the chart of accounts from the template for the entity type, then record opening balances.",
));
next_steps.push(step(
"books.handbook",
Some(serde_json::json!({"section": "contract"})),
"New books: onboard first. Ask the human for the company's facts (books.set_setting), set up the chart (books.chart_template), and record opening balances.",
"The rules, before anything is recorded.",
));
}
let due_soon = today + chrono::Days::new(30);
@@ -854,22 +1022,133 @@ impl BooksServer {
// draws (ledger_package::attention), so the agent and the human see
// one list. The most urgent first (the list is sorted by severity),
// at most 12: `books.start` stays small (INVARIANT 4).
{
let attention = {
let attention_settings = ledger_package::Settings {
company: &cfg.company,
cash_accounts: &cfg.roles.cash,
financing_accounts: &cfg.roles.financing,
investing_accounts: &cfg.roles.investing,
};
let attention = ledger_package::find(store, today, &attention_settings);
for a in attention.iter().filter(|a| a.tool.is_some()).take(12) {
let Some((tool, args)) = &a.tool else {
continue;
};
let args = (!args.is_null()).then(|| args.clone());
next_steps.push(step(tool, args, &step_title(&a.title)));
}
ledger_package::find(store, today, &attention_settings)
};
for a in attention.iter().filter(|a| a.tool.is_some()).take(12) {
let Some((tool, args)) = &a.tool else {
continue;
};
let args = (!args.is_null()).then(|| args.clone());
let mut s = step(tool, args, &step_title(&a.title));
s.required = a.severity == ledger_package::Severity::Problem;
next_steps.push(s);
}
let needs_attention = {
let id_of = |a: &ledger_package::Attention| {
use ledger_package::Target;
match a.target {
Target::Entry(n) => format!("{}:entry:{n}", a.kind),
Target::Invoice(n) => format!("{}:invoice:{n}", a.kind),
Target::Account(n) => format!("{}:account:{n}", a.kind),
Target::Party(n) => format!("{}:party:{n}", a.kind),
_ => a.kind.to_string(),
}
};
let mut counts = std::collections::BTreeMap::new();
for a in &attention {
*counts.entry(a.kind.to_string()).or_insert(0u32) += 1;
}
NeedsAttentionOut {
items: attention
.iter()
.take(12)
.map(|a| AttentionBriefOut {
id: id_of(a),
severity: match a.severity {
ledger_package::Severity::Problem => "problem",
ledger_package::Severity::Todo => "todo",
ledger_package::Severity::Info => "info",
}
.into(),
kind: a.kind.to_string(),
title: step_title(&a.title),
tool: a.tool.as_ref().map(|(t, _)| t.clone()),
args: a
.tool
.as_ref()
.map(|(_, args)| args.clone())
.filter(|v| !v.is_null()),
})
.collect(),
counts,
}
};
// Drafts: building, and those waiting for their documents.
let drafts = {
let mut building = 0u32;
let mut awaiting = Vec::new();
for e in ledger.drafts() {
if e.draft_state().key() == "awaiting_evidence" {
awaiting.push(StartDraftOut {
entry_id: e.id(),
date: e.date().to_string(),
amount: ledger_core::evidence::Policy::entry_debits(e)
.map(money)
.unwrap_or_else(|_| "too large".to_string()),
note: e.draft_state().note().chars().take(100).collect(),
});
} else {
building += 1;
}
}
awaiting.sort_by_key(|d| (d.date.clone(), d.entry_id));
awaiting.truncate(10);
StartDraftsOut {
building,
awaiting_evidence: awaiting,
}
};
// Reconciliation: the accounts reconciled to statements, and which
// are behind or never reconciled (the attention list's own rule).
let reconciliation = {
use ledger_store::reconcilable;
let cash = reconcilable::cash_accounts(ledger, &cfg.roles.cash);
let statements = store.statements(None).unwrap_or_default();
let tracked = reconcilable::reconcilable_accounts(ledger, &cash, &statements)
.into_iter()
.map(|id| code_name(store, id).0)
.collect();
let (mut never, mut behind) = (Vec::new(), Vec::new());
for u in self.unreconciled_accounts(store, today).unwrap_or_default() {
if u.reconciled_through.is_some() {
behind.push(u.code);
} else {
never.push(u.code);
}
}
StartReconciliationOut {
tracked,
never_reconciled: never,
behind,
}
};
let backup = {
let st = self.backups.standing(store);
StartBackupOut {
last_at: st.targets.iter().filter_map(|t| t.last_ok_at.clone()).max(),
through_seq: st.targets.iter().filter_map(|t| t.through_seq).max(),
stale: st.needs_attention(),
}
};
let you = YOU.with(|y| y.borrow().clone()).and_then(|v| {
Some(YouOut {
principal: v["principal"].as_str()?.to_string(),
kind: v["kind"].as_str()?.to_string(),
scopes: v["scopes"]
.as_array()?
.iter()
.filter_map(|s| s.as_str().map(str::to_string))
.collect(),
client_id: v["client_id"].as_str().map(str::to_string),
})
});
match focus {
"close" => next_steps.push(step(
"books.conventions",
@@ -883,6 +1162,13 @@ impl BooksServer {
)),
_ => {}
}
if !onboarding {
next_steps.push(step(
"books.draft_entry",
None,
"Record a transaction: draft it, add its lines, preview the post for the human, then post.",
));
}
next_steps.push(step(
"books.handbook",
Some(serde_json::json!({"section": "contract"})),
@@ -895,6 +1181,19 @@ impl BooksServer {
));
let mut out = StartOut {
book: StartBookOut {
id: store
.data_dir()
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
url: format!(
"/b/{}/mcp",
store
.data_dir()
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
),
// A settings file's name is unbounded; the books' at most
// 200 characters (INVARIANT 4).
name: cfg
@@ -947,6 +1246,18 @@ impl BooksServer {
adds: u.adds(),
how: "only the owner, with the owner passphrase: in the app (it asks when it opens the books) or `wanderbooks books upgrade ID` at a terminal; a verified backup with the keys is made first. No tool can.".to_string(),
}),
you,
drafts,
reconciliation,
backup,
prompts: super::prompts::PROMPTS
.iter()
.map(|p| StartPromptOut {
name: p.name.to_string(),
args: p.args.iter().map(|(n, _, _)| n.to_string()).collect(),
})
.collect(),
needs_attention,
next_steps,
isolation: self.isolation.label().to_string(),
};
@@ -975,6 +1286,14 @@ impl BooksServer {
c.summary.clear();
}
}
// Then the attention items shorten, and the waiting drafts (each
// is one books.list_drafts or the attention tools away).
while size(&out) > START_MAX && out.needs_attention.items.len() > 4 {
out.needs_attention.items.pop();
}
while size(&out) > START_MAX && out.drafts.awaiting_evidence.len() > 3 {
out.drafts.awaiting_evidence.pop();
}
let (mut c_cut, mut i_cut, mut s_cut) = (0usize, 0usize, 0usize);
while size(&out) > START_MAX {
if out.conventions.len() > 10 {
@@ -1018,7 +1337,8 @@ impl BooksServer {
name = "books.start",
title = "Start here",
description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the projects under way, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.",
annotations(read_only_hint = true, open_world_hint = false)
annotations(read_only_hint = true, open_world_hint = false),
meta = always_load()
)]
pub(super) async fn start(
&self,
@@ -1029,6 +1349,17 @@ impl BooksServer {
{
return Err(format!("focus {f:?}: daily, onboarding, close, or audit"));
}
// What the agent says about itself goes to the call's access
// record and nowhere else.
if let Some(a) = params.0.agent {
AGENT_NOTE.with(|n| {
*n.borrow_mut() = Some(ledger_store::access::Agent {
model: a.model,
reasoning_effort: a.reasoning_effort,
harness: a.harness,
});
});
}
let store = self.lock()?;
Ok(Json(self.start_out(&store, params.0.focus.as_deref())))
}
+25
View File
@@ -67,6 +67,22 @@ pub struct Client {
pub version: String,
}
/// What an agent said about itself when it began its session
/// (`books.start`'s `agent`): self-reported, never checked, kept beside the
/// access so a record shows which model and harness made it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Agent {
/// The model's name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// The reasoning effort it ran at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
/// The program driving it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub harness: Option<String>,
}
/// What came of a call.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -105,6 +121,9 @@ pub struct Access {
/// (whatever it answered).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head_after: Option<Head>,
/// What the agent said about itself, on a `books.start` that said.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<Agent>,
}
/// Mask any full TIN in a client's text, then cut it to
@@ -186,6 +205,11 @@ impl AccessLog {
c.name = asserted(&c.name);
c.version = asserted(&c.version);
}
if let Some(a) = &mut access.agent {
for f in [&mut a.model, &mut a.reasoning_effort, &mut a.harness] {
*f = f.as_deref().map(asserted);
}
}
access.request_id = access.request_id.as_deref().map(asserted);
access.subject = asserted(&access.subject);
let Value::Object(fields) = serde_json::to_value(&access).expect("a record serializes")
@@ -261,6 +285,7 @@ mod tests {
args_digest: Some(digest(&serde_json::json!({"as_of": "2026-10-19"}))),
result: Outcome::Ok,
head_after: None,
agent: None,
}
}