From 453abb62ea85329b391f3bf27a891a21fa9f96a7 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 14:59:04 -0600 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-mcp/src/access.rs | 13 + crates/ledger-mcp/src/local.rs | 3 + crates/ledger-mcp/src/server.rs | 27 +- crates/ledger-mcp/src/server_tests.rs | 14 + crates/ledger-mcp/src/tools/kept.rs | 357 +++++++++++++++++++++++++- crates/ledger-store/src/access.rs | 25 ++ docs/mcp-interface.md | 45 +++- 7 files changed, 459 insertions(+), 25 deletions(-) diff --git a/crates/ledger-mcp/src/access.rs b/crates/ledger-mcp/src/access.rs index 7631d56..31915e2 100644 --- a/crates/ledger-mcp/src/access.rs +++ b/crates/ledger-mcp/src/access.rs @@ -86,6 +86,7 @@ pub(crate) struct Call { action: &'static str, subject: String, args_digest: Option, + agent: Option, } /// 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) { + self.agent = agent; + } + + /// The client program's name, as it named itself at `initialize`. + pub(crate) fn client_name(&self) -> Option { + 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(()), diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index 256619f..043d4d5 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -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) diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 56885cc..9d4a524 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -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> = 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> = 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> = 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::>(), + "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. diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs index 7d3ce2d..0e05ecc 100644 --- a/crates/ledger-mcp/src/server_tests.rs +++ b/crates/ledger-mcp/src/server_tests.rs @@ -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] diff --git a/crates/ledger-mcp/src/tools/kept.rs b/crates/ledger-mcp/src/tools/kept.rs index 4240d9f..8566b89 100644 --- a/crates/ledger-mcp/src/tools/kept.rs +++ b/crates/ledger-mcp/src/tools/kept.rs @@ -94,11 +94,35 @@ pub struct StartParams { /// (shapes next_steps). Default: daily. #[serde(default)] pub focus: Option, + /// 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, +} + +/// 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, + /// The reasoning effort it runs at ("high"). + #[serde(default)] + pub reasoning_effort: Option, + /// The program driving it ("claude-code"). + #[serde(default)] + pub harness: Option, } /// 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, + /// Or the prompt to run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, /// Its arguments, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub args: Option, /// 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, + /// The client program, as it named itself. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, +} + +/// 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, +} + +/// Where reconciliation stands. +#[derive(Debug, Serialize, JsonSchema)] +pub struct StartReconciliationOut { + /// The accounts reconciled to statements (codes). + pub tracked: Vec, + /// Of those, never reconciled. + pub never_reconciled: Vec, + /// Of those, reconciled but behind. + pub behind: Vec, +} + +/// 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, + /// The journal seq the latest holds. + pub through_seq: Option, + /// 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, +} + +/// 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, + /// Its arguments, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args: Option, +} + +/// 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, + /// Every item by kind. + pub counts: std::collections::BTreeMap, } /// 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, + /// Who is asking. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub you: Option, + /// 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, + /// What needs attention, most urgent first. + pub needs_attention: NeedsAttentionOut, /// What to do next, in order. pub next_steps: Vec, /// 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, 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()))) } diff --git a/crates/ledger-store/src/access.rs b/crates/ledger-store/src/access.rs index c5aedf6..ac0282b 100644 --- a/crates/ledger-store/src/access.rs +++ b/crates/ledger-store/src/access.rs @@ -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, + /// The reasoning effort it ran at. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + /// The program driving it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness: Option, +} + /// 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, + /// What the agent said about itself, on a `books.start` that said. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, } /// 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, } } diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index c95ade5..688be57 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -106,15 +106,37 @@ Conventions, decisions, open items and approvals live in the books too when; `books.start` lists them, and the handbook's settings-and-conventions section says how to use them. -- `books.start { focus? }` (read-only, at most 8 KB) → `{ book: { name, - timezone, today, fiscal_year, base_currency, basis }, head: { seq, hash }, +- `books.start { focus?, agent? }` (read-only, at most 8 KB; `_meta` + `anthropic/alwaysLoad: true`, so a client that defers tools loads it up + front) → `{ book: { id, url, name, + timezone, today, fiscal_year, base_currency, basis }, you?: { principal, + kind, scopes, client_id? }, head: { seq, hash }, state ("needs_onboarding" or "active"), rules: { evidence, parties, approval_rules }, settings_summary: [{ key, value, set_by, seq }], conventions: [{ id, kind, title, summary }], open_items: [{ id, title, due }], projects: [{ id, name, status, kind? }], - truncated?, handbook_sections, format_upgrade?: { - guard, format, adds, how }, next_steps: [{ tool, args?, why }], - isolation }`. The connect text names it as the first call. + drafts: { building, awaiting_evidence: [{ entry_id, date, amount, note + }] }, reconciliation: { tracked, never_reconciled, behind } (account + codes), backup: { last_at, through_seq, stale }, prompts: [{ name, args + }], needs_attention: { items: [{ id, severity, kind, title, tool?, args? + }], counts: { kind: n } }, truncated?, handbook_sections, + format_upgrade?: { guard, format, adds, how }, next_steps: [{ tool | + prompt, args?, why, required }], isolation }`. The connect text names it + as the first call. `book.url` is the book's path on this server + (`/b/ID/mcp`), never a path on a disk. `you` is the token's own facts + (its name, the kind `agent`, its scopes) and the client program's name; it is + absent on the `books://start` resource, which no token's call made. + `agent: { model?, reasoning_effort?, harness? }` is what the agent says + about itself: self-reported, never checked, written to the call's access + record (`books.access_log` shows it as `agent`) and used for nothing + else. `drafts` lists the first ten drafts waiting on their evidence; + `reconciliation` the accounts reconciled to statements and which are + behind or never reconciled; `needs_attention` the twelve most urgent + items (problems first) with every item counted by kind: the same list + the viewer draws, from one function (`ledger_package::attention`). If + the answer would pass 8 KiB it shortens, in this order: convention + summaries, attention items (to four), waiting drafts (to three), then + the lists below, each saying how many more in `truncated`. `format_upgrade` is there only for books waiting for a format upgrade (below); its next step comes first, before anything else, at every start until it is done: `books.handbook {section: "format-upgrade"}`, @@ -128,8 +150,9 @@ settings-and-conventions section says how to use them. `projects` lists the active and paused projects by name, at most 20 ("projects: N more (books.projects)" past that). `next_steps`, in order: the format upgrade's (when waiting); the - onboarding step (`books.handbook {section: "contract"}`, when the chart - is empty or `focus` is `onboarding`); `books.open_items` (an item due + onboarding steps (the `onboard` prompt, `books.set_setting`, + `books.chart_template` and `books.handbook {section: "contract"}`, when + the chart is empty or `focus` is `onboarding`); `books.open_items` (an item due within 30 days or overdue); `books.backup` (none set up, or one stale, failing, paused or failing its drill); then Needs attention, by severity, at most 12, each one `{tool, args?, why}` whose `why` is the @@ -138,11 +161,11 @@ settings-and-conventions section says how to use them. server's own damage check and are not repeated); then the focus's step (`close`: `books.conventions {kind: "close_step"}`; `audit`: `books.verify`); then - always `books.handbook {section: "contract"}` and `books.digest`. + `books.draft_entry` (the books are set up), `books.handbook {section: + "contract"}` and `books.digest`. Attention steps are `required` when the + item is a problem. `focus` is `daily` (the default), `onboarding`, `close` or `audit`; - anything else is refused. Not built yet (P5): - `book.id`, `book.url`, `head.last_handoff`, the `agent` input, and - loading it up front under tool search. Also `books://start`. + anything else is refused. Also `books://start`. **The format upgrade** (plan 3.9, D-39, rules.md B-27). Books an older wanderbooks wrote are in an older journal format: they are served on it, and every tool that needs nothing new works as before. A tool whose