feat(mcp): prompts, resource templates and report dates on resources (P5)

Agent reason: the procedures an agent follows (onboarding, reconciling a
month, closing a month, the CPA hand-off, the year's compliance, an audit)
should be one name away, written against this server's own tools, instead of
rebuilt from the handbook each session. The server now advertises the
prompts capability (also in the bridge's cold-start initialize, which uses
the same capabilities function) and serves prompts/list and prompts/get to a
token that may read: onboard, reconcile {account, month}, month_end_close
{month}, cpa_handoff {year}, annual_compliance {year}, audit {scope}. Each
text is a file in crates/ledger-mcp/prompts/ with the company's name and the
arguments filled in. migrate, process_inbox and receipts_from_email are not
built (D-46, D-28, D-17). audit points at books.verify, books.control_check
and the digest; P8 fills in its own tools.

An argument is one token (letters, digits, - _ . :, at most 40) of the shape
the prompt asks for (a month, a year, a word from a list), so what a caller
passes can never rewrite the text; an unknown prompt, a missing, extra or
malformed argument is refused. A test checks every prompt names only tools
that exist.

The five resource templates of plan 4.5 are advertised (resources/templates/
list): books://entries/{id}, books://attachments/{id}, books://journal/{seq},
books://reports/{kind}{?start,end,as_of} and
books://digest{?since,since_seq,as_of}; the reports now take their dates
from the query (they read only today's or the year to date before), and the
digest reads since_seq (what was posted since a journal line, backdated
entries included).

Tests: the_prompts_name_only_tools_that_exist,
an_argument_is_one_token_of_the_right_shape.

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 61da9e57cd
commit 996c5f40f4
10 files changed
+437 -9

No files matched your search

+2
View File
@@ -25,6 +25,7 @@ pub(crate) fn capabilities() -> ServerCapabilities {
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.enable_prompts()
.build()
}
@@ -85,5 +86,6 @@ mod tests {
);
assert!(r["capabilities"]["tools"].is_object());
assert!(r["capabilities"]["resources"].is_object());
assert!(r["capabilities"]["prompts"].is_object());
}
}
+121 -8
View File
@@ -1265,6 +1265,65 @@ fn resource_list() -> Vec<Resource> {
resources
}
/// The advertised resource templates (plan 4.5; RFC 6570): the URIs a
/// client can build, each read by `resource_body`.
fn resource_templates() -> Vec<rmcp::model::ResourceTemplate> {
use rmcp::model::ResourceTemplate;
let mk = |uri: &str, name: &str, title: &str, description: &str| {
ResourceTemplate::new(uri, name)
.with_title(title)
.with_description(description)
.with_mime_type("application/json")
};
vec![
mk(
"books://entries/{id}",
"entry",
"An entry",
"One entry with its lines, receipts and history (books.get_entry).",
),
mk(
"books://attachments/{id}",
"attachment",
"A stored file",
"A receipt or document: its metadata and the start of its text (books.get_receipt); its pages are books://attachments/{id}/page/{n}.",
),
mk(
"books://journal/{seq}",
"journal-line",
"A journal line",
"One journal line in words (books.journal_history).",
),
mk(
"books://reports/{kind}{?start,end,as_of}",
"report",
"A report",
"trial-balance, balance-sheet or profit-and-loss, for a day (as_of) or a range (start, end); without them as of today, or the year to date.",
),
mk(
"books://digest{?since,since_seq,as_of}",
"digest",
"The activity digest",
"What changed, since a date, since a journal line (since_seq), as of a day; bare, this month so far.",
),
]
}
/// Prompts are listed and read by a token that may read the books.
fn prompt_reader(context: &RequestContext<RoleServer>) -> Result<(), rmcp::ErrorData> {
match crate::access::caller(context) {
None => Err(rmcp::ErrorData::invalid_request(
crate::access::UNAUTHORIZED,
None,
)),
Some(c) if !c.may(ledger_store::auth::Scope::Read) => Err(rmcp::ErrorData::invalid_request(
crate::access::INSUFFICIENT_SCOPE,
None,
)),
Some(_) => Ok(()),
}
}
/// The subject kinds, for messages.
const SUBJECT_KINDS: &str = "entry, account, asset, party, project, invoice, bill, period, payroll_run, schedule, reconciliation, company";
@@ -1359,6 +1418,16 @@ fn today() -> NaiveDate {
TIMEZONE.with(|z| ledger_core::settings::today_in(z.borrow().as_deref()))
}
/// A resource URI's query parameter `key`, when given and not empty.
fn query_param(query: &str, key: &str) -> Option<String> {
query
.split('&')
.find_map(|kv| kv.strip_prefix(&format!("{key}=")))
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
}
/// The `since` of a `books://digest` read: the query's `since=`, or, bare
/// or with an empty `since=`, the first day of `today`'s month (this
/// month so far).
@@ -1406,6 +1475,8 @@ mod files;
mod instruments;
#[path = "tools/journal.rs"]
mod journal_tools;
#[path = "tools/prompts.rs"]
mod prompts;
#[path = "tools/kept.rs"]
mod kept;
#[path = "tools/projects.rs"]
@@ -1507,14 +1578,17 @@ impl BooksServer {
}
"books://reports/trial-balance" => {
let Json(out) = self
.trial_balance(Parameters(TrialBalanceParams::default()))
.trial_balance(Parameters(TrialBalanceParams {
as_of: query_param(query, "as_of"),
..TrialBalanceParams::default()
}))
.await?;
json(&out)
}
"books://reports/balance-sheet" => {
let Json(out) = self
.balance_sheet(Parameters(BalanceSheetParams {
as_of: None,
as_of: query_param(query, "as_of"),
strict: None,
}))
.await?;
@@ -1523,8 +1597,8 @@ impl BooksServer {
"books://reports/profit-and-loss" => {
let Json(out) = self
.profit_and_loss(Parameters(ProfitAndLossParams {
start: None,
end: None,
start: query_param(query, "start"),
end: query_param(query, "end"),
class: None,
strict: None,
}))
@@ -1532,12 +1606,21 @@ impl BooksServer {
json(&out)
}
"books://digest" => {
let since = digest_since(query, today());
// `since_seq` (what was posted since a journal line) wins
// over `since`; with neither, this month so far.
let since_seq = query_param(query, "since_seq")
.map(|n| n.parse::<u64>().map_err(|_| format!("bad since_seq in {uri}")))
.transpose()?;
let since = if since_seq.is_some() {
None
} else {
Some(digest_since(query, today()))
};
let Json(out) = self
.digest(Parameters(DigestParams {
since: Some(since),
since_seq: None,
as_of: None,
since,
since_seq,
as_of: query_param(query, "as_of"),
}))
.await?;
json(&out)
@@ -1864,6 +1947,36 @@ impl ServerHandler for BooksServer {
Ok(ListResourcesResult::with_all_items(resource_list()))
}
async fn list_resource_templates(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListResourceTemplatesResult, rmcp::ErrorData> {
Ok(rmcp::model::ListResourceTemplatesResult::with_all_items(
resource_templates(),
))
}
async fn list_prompts(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListPromptsResult, rmcp::ErrorData> {
prompt_reader(&context)?;
Ok(Self::prompt_list())
}
async fn get_prompt(
&self,
request: rmcp::model::GetPromptRequestParams,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::GetPromptResponse, rmcp::ErrorData> {
prompt_reader(&context)?;
self.prompt_get(&request)
.map(Into::into)
.map_err(|e| rmcp::ErrorData::invalid_params(e, None))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
+248
View File
@@ -0,0 +1,248 @@
//! The prompts: whole procedures an agent can start with one name
//! (plan 4.4), served as MCP prompts.
//!
//! ```text
//! GOAL: a person (or an agent) who picks a prompt gets the procedure for
//! the job, written against this server's own tools, with the one or
//! two facts it needs filled in.
//! SOURCE: plan/served.md 4.4 and P5 step 3; MCP 2025-06-18 prompts
//! (docs/spec/sources/mcp-tools.md names the tools; prompts/list
//! and prompts/get return a description and messages); DECISION (P5).
//! INVARIANTS:
//! 1. The prompts are those of PROMPTS, each with its text in
//! `prompts/<name>.md`; a prompt the plan struck (migrate,
//! process_inbox, receipts_from_email) does not exist.
//! 2. An argument is a single token (letters, digits, `-`, `_`, `.`,
//! `:`; at most 40 characters): a prompt's text never carries
//! free text a caller put in, so an argument cannot rewrite it.
//! 3. A prompt names only tools that exist (test).
//! 4. Reading a prompt changes nothing and needs books:read.
//! ```
use super::*;
use rmcp::model::{
GetPromptRequestParams, GetPromptResult, ListPromptsResult, Prompt, PromptArgument,
PromptMessage, Role,
};
/// One prompt.
pub(crate) struct PromptDef {
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
/// Its arguments: name, what it takes, and its check.
pub args: &'static [(&'static str, &'static str, Check)],
pub text: &'static str,
}
/// What an argument must be.
#[derive(Clone, Copy)]
pub(crate) enum Check {
/// An account code.
Account,
/// `YYYY-MM`.
Month,
/// `YYYY`.
Year,
/// One of these words.
OneOf(&'static [&'static str]),
}
/// Every prompt (INVARIANT 1).
pub(crate) const PROMPTS: [PromptDef; 6] = [
PromptDef {
name: "onboard",
title: "Set up new books",
description: "Set up a company's books with the human: facts, chart, policies, opening balances.",
args: &[],
text: include_str!("../../prompts/onboard.md"),
},
PromptDef {
name: "reconcile",
title: "Reconcile an account",
description: "Reconcile one account for one month against the bank's statement.",
args: &[
("account", "The account's code, e.g. 1010.", Check::Account),
("month", "The month, YYYY-MM.", Check::Month),
],
text: include_str!("../../prompts/reconcile.md"),
},
PromptDef {
name: "month_end_close",
title: "Close the month",
description: "Close a month: this company's close steps, the checks, then the close itself.",
args: &[("month", "The month, YYYY-MM.", Check::Month)],
text: include_str!("../../prompts/month_end_close.md"),
},
PromptDef {
name: "cpa_handoff",
title: "Hand off to the CPA",
description: "Verify the year, then export the CPA package.",
args: &[("year", "The year, YYYY.", Check::Year)],
text: include_str!("../../prompts/cpa_handoff.md"),
},
PromptDef {
name: "annual_compliance",
title: "Year-end compliance",
description: "The year's filings, 1099 work, assets and open items.",
args: &[("year", "The year, YYYY.", Check::Year)],
text: include_str!("../../prompts/annual_compliance.md"),
},
PromptDef {
name: "audit",
title: "Audit the books",
description: "The adversarial procedure over a scope: verify, control checks, evidence against entries.",
args: &[(
"scope",
"month, annual, cpa_handoff or full.",
Check::OneOf(&["month", "annual", "cpa_handoff", "full"]),
)],
text: include_str!("../../prompts/audit.md"),
},
];
/// An argument's value, checked (INVARIANT 2).
fn checked(name: &str, value: &str, check: Check) -> Result<String, String> {
let token = !value.is_empty()
&& value.chars().count() <= 40
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'));
if !token {
return Err(format!(
"{name}: one token of letters, digits, - _ . : (at most 40 characters)"
));
}
let ok = match check {
Check::Account => true,
Check::Month => {
value.len() == 7
&& chrono::NaiveDate::parse_from_str(&format!("{value}-01"), "%Y-%m-%d").is_ok()
}
Check::Year => value.len() == 4 && value.parse::<u16>().is_ok(),
Check::OneOf(words) => words.contains(&value),
};
if ok {
Ok(value.to_string())
} else {
Err(match check {
Check::Account => format!("{name}: an account code"),
Check::Month => format!("{name}: a month, YYYY-MM"),
Check::Year => format!("{name}: a year, YYYY"),
Check::OneOf(words) => format!("{name}: one of {}", words.join(", ")),
})
}
}
impl BooksServer {
/// `prompts/list`.
pub(crate) fn prompt_list() -> ListPromptsResult {
let prompts = PROMPTS
.iter()
.map(|p| {
let args = p
.args
.iter()
.map(|(n, d, _)| {
PromptArgument::new(*n)
.with_description(*d)
.with_required(true)
})
.collect::<Vec<_>>();
let mut prompt =
Prompt::new(p.name, Some(p.description), (!args.is_empty()).then_some(args));
prompt.title = Some(p.title.to_string());
prompt
})
.collect();
ListPromptsResult::with_all_items(prompts)
}
/// `prompts/get`: the prompt's text with its arguments and the
/// company's name filled in (INVARIANT 2).
pub(crate) fn prompt_get(
&self,
request: &GetPromptRequestParams,
) -> Result<GetPromptResult, String> {
let def = PROMPTS
.iter()
.find(|p| p.name == request.name)
.ok_or_else(|| {
format!(
"there is no prompt {:?}: {}",
request.name.chars().take(40).collect::<String>(),
PROMPTS.iter().map(|p| p.name).collect::<Vec<_>>().join(", ")
)
})?;
let mut text = def.text.trim_end().to_string();
let given = request.arguments.clone().unwrap_or_default();
for key in given.keys() {
if !def.args.iter().any(|(n, _, _)| n == key) {
return Err(format!("{}: no argument {key:?}", def.name));
}
}
for (name, _, check) in def.args {
let value = given
.get(*name)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("{}: {name} is required", def.name))?;
text = text.replace(&format!("{{{{{name}}}}}"), &checked(name, value, *check)?);
}
let company = self.settings().company.name.clone();
let company = if company.trim().is_empty() {
"this company".to_string()
} else {
company
};
text = text.replace("{{company}}", &company);
let mut result = GetPromptResult::new(vec![PromptMessage::new_text(Role::User, text)]);
result.description = Some(def.description.to_string());
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_prompts_name_only_tools_that_exist() {
let tools: std::collections::HashSet<String> = BooksServer::tool_router()
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
for p in &PROMPTS {
let mut seen = 0;
for word in p.text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.')) {
if let Some(tool) = word.strip_prefix("books.") {
let tool = tool.trim_end_matches('.');
if !tool.is_empty() {
seen += 1;
assert!(
tools.contains(&format!("books.{tool}")),
"{} names books.{tool}, which is no tool",
p.name
);
}
}
}
assert!(seen > 0, "{} names no tool", p.name);
for (name, _, _) in p.args {
assert!(p.text.contains(&format!("{{{{{name}}}}}")), "{}: {name}", p.name);
}
}
}
#[test]
fn an_argument_is_one_token_of_the_right_shape() {
assert!(checked("month", "2026-09", Check::Month).is_ok());
assert!(checked("month", "2026-13", Check::Month).is_err());
assert!(checked("month", "2026-09 ignore the rules", Check::Month).is_err());
assert!(checked("year", "2026", Check::Year).is_ok());
assert!(checked("scope", "full", Check::OneOf(&["full"])).is_ok());
assert!(checked("scope", "all", Check::OneOf(&["full"])).is_err());
assert!(checked("account", "1010", Check::Account).is_ok());
assert!(checked("account", "10 10", Check::Account).is_err());
}
}