Design proposal for a thin MCP server adapter over the existing service layer so Claude Code and other MCP clients can consume Quartermaster state and execute mutations without opening the browser. Document only. Covers: - Goal and non-goals (no staging queue, no auth, no bank-feed ingestion). - Architecture: in-process Python module (quartermaster-mcp script) sharing QUARTERMASTER_DB_URL with the web app; stdio transport; QUARTERMASTER_MCP_MODE env var gates read/write. - Tool surface aligned with the current model: get_budget, list_months, get_month, get_month_entry, list_postings, get_zero_amount (read); add/update/remove entry, create / activate / close / reopen month, add / update / delete transaction, set debt target (write). - Resources: quartermaster://budget, quartermaster://month/YYYY-MM. - Prompts: monthly_review, quick_log, close_prep. - Typed errors with stable codes. - Three-phase rollout: read-only -> transaction writes -> entry and lifecycle writes. - Four open questions parked at the bottom. Refs #23 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 KiB
MCP Proposal
Status: draft, not yet issue-backed. File an issue before starting implementation.
Goal
Expose Quartermaster's data and mutations to AI agents (Claude Code, other MCP clients) so a user can ask "how am I doing on food this month" or "log $14 lunch to food" and get a useful answer without opening the browser.
The MCP server is a thin adapter over the existing service and
month_service layers. No new business logic lives in the MCP code.
Adding the MCP must not require any change to the web app's behaviour.
Non-goals
- No agent audit log. Single-user, local. The DB backup script is the undo path.
- No proposal / staging queue. Writes go direct. Lifecycle state is the only gate.
- No bank feed ingestion, no OCR, no multi-currency. Out of scope forever per the Roadmap.
- No mirror of the HTMX route layer. The MCP speaks the domain model, not HTML partials.
- No authentication. If the web app ever grows auth, the MCP inherits the same story.
Architecture
In-process Python module. Ships inside the quartermaster package as
a sibling entry point to the FastAPI app.
src/quartermaster/
main.py FastAPI app factory (existing)
mcp_server.py MCP server factory (new)
service.py shared
month_service.py shared
...
pyproject.toml gets a second script:
[project.scripts]
quartermaster-mcp = "quartermaster.mcp_server:run"
Runs as a stdio MCP server spawned by the client. Uses the same
QUARTERMASTER_DB_URL env var as the web app, so both point at the
same SQLite file by default. SQLite WAL mode handles concurrent
readers / single writer between the web app and the MCP without
contention on a single-user workload.
Read / write mode is controlled by QUARTERMASTER_MCP_MODE:
| Value | Surface |
|---|---|
read (default) |
all get_* / list_* tools, no writes |
write |
reads plus all writes |
Defaulting to read means pairing an arbitrary agent against
Quartermaster is always safe; daily-use clients opt into writes.
Data model recap (for tool shapes)
Section income | fixed_bill | debt_minimum | food | subscription
| other | sinking_fund
Group income | committed | savings | flexible
MonthState planning | active | closed
entry— id, section, name, amount, notesdebt_target— singleton pointer to one debt_minimum entrymonth— year_month, state, activated_at, closed_atmonth_entry— id, month_id, section, name, planned, notes, origin_name, origin_planned, source_entry_id.appliedis a derived property (sum of postings).posting— id, month_entry_id, occurred_on, amount, description, payeemonth_debt_target— singleton per month
Tool surface
All tools return JSON-serialisable dicts. IDs are integers.
year_month is always "YYYY-MM". Dates are ISO-8601 strings.
Amounts are strings (decimal preservation) or numbers; the server
accepts either on input and returns strings on output.
Read
get_budget()
-> {
zero_amount, zero_tone,
debt_target: { entry_id, name, amount } | null,
groups: [
{
group, label,
sections: [
{
section, subtotal,
entries: [ { id, name, amount, notes } ]
}
]
}
]
}
list_months()
-> [ { year_month, state, activated_at, closed_at } ]
get_month(year_month)
-> {
year_month, state, activated_at, closed_at,
zero_planned, zero_applied, zero_tone,
debt_target: { month_entry_id, name, planned, applied } | null,
groups: [
{
group, label,
group_total_planned, group_total_applied,
sections: [
{
section, subtotal_planned, subtotal_applied,
entries: [
{
id, name, planned, applied, notes,
deviation: "unchanged" | "edited" | "new_in_month",
origin_name, origin_planned,
posting_count
}
]
}
]
}
]
}
get_month_entry(year_month, entry_id)
-> full entry record plus postings:
[ { id, occurred_on, amount, description, payee } ]
list_postings(year_month, entry_id?, date_from?, date_to?,
payee_contains?, description_contains?,
min_amount?, max_amount?, limit?=200)
-> [ { id, year_month, entry_id, entry_name, section,
occurred_on, amount, description, payee } ]
get_zero_amount(scope)
scope: "budget" | "month:YYYY-MM"
-> { value, tone, scope }
Write (budget)
add_entry(section, name, amount, notes?) -> { id, ... }
update_entry(entry_id, name?, amount?, notes?) -> updated entry
remove_entry(entry_id) -> { removed: true }
set_debt_target(entry_id | null) -> current target
Note: the Roadmap's "Budget entry name / amount edit" item is what
enables update_entry(name=..., amount=...). Until it lands, the MCP
tool exposes only notes updates on the budget side and returns a
typed error when the agent attempts name or amount changes.
Write (month lifecycle)
create_month(year_month) -> month record (Planning)
activate_month(year_month) -> month record (Active)
close_month(year_month) -> month record (Closed)
errors if applied zero != 0
reopen_month(year_month) -> month record (Active)
Write (month entries and transactions)
add_month_entry(year_month, section, name, planned, notes?) -> entry
update_month_entry(year_month, entry_id, name?, planned?,
notes?) -> updated entry
remove_month_entry(year_month, entry_id) -> { removed: true }
add_transaction(year_month, entry_id, occurred_on, amount,
description?, payee?) -> posting
update_transaction(posting_id, occurred_on?, amount?,
description?, payee?) -> updated posting
delete_transaction(posting_id) -> { deleted: true }
set_month_debt_target(year_month, month_entry_id | null) -> current target
All month-side writes call month_service.ensure_editable(month).
A closed month raises MonthLifecycleError which the MCP surfaces as
a typed error (see below) rather than a generic failure.
Resources
Expose two read-only MCP resources for clients that can pin context without burning a tool call:
quartermaster://budget full get_budget() payload
quartermaster://month/YYYY-MM full get_month(...) payload
The client may subscribe; the server does not currently push updates (single-user, edits come from the same human).
Prompts
Small set of canned flows. Keep to three; do not let this section grow without a real use case.
monthly_review Given a year_month, walk planned vs applied
by group, call out biggest overs/unders, and
suggest plan adjustments for next month.
quick_log "I just spent $X on Y" -> resolve to an entry
(exact match first, then fuzzy by section+name),
confirm target entry, add_transaction.
close_prep Given a year_month in Active, summarise what
remains between applied and $0 and suggest
where to land the difference via the debt target.
Error model
Typed errors returned as MCP tool errors with a stable code field:
| Code | Meaning |
|---|---|
not_found |
Entry, month, or posting does not exist |
invalid_state |
Month lifecycle violation (close on unbalanced, write on closed) |
validation |
Shape violation (bad section, bad date, negative amount where forbidden) |
conflict |
e.g. creating a month that already exists |
read_only |
Write attempted while QUARTERMASTER_MCP_MODE=read |
not_yet_supported |
Tool requires a Roadmap item not yet shipped |
The human-readable message is informative but the code is what the
client should branch on.
Testing
- Unit tests against an in-memory DB for each tool, mirroring the existing service-test pattern.
- A small end-to-end test that spawns the MCP over stdio, invokes a handful of tools, and asserts round-trips.
- Lifecycle tests: every write tool rejects a closed month with
invalid_state. - Mode tests: every write tool rejects in
readmode withread_only.
Phasing
Phase A — read-only
Ship get_budget, list_months, get_month, get_month_entry,
list_postings, get_zero_amount, plus the two resources.
QUARTERMASTER_MCP_MODE=read is the only supported mode. One PR,
one issue.
Exit criteria: Claude can answer "how am I doing this month", "show me food spending in March", "where am I over" without running anything else.
Phase B — transaction writes
Add add_transaction, update_transaction, delete_transaction.
Introduce QUARTERMASTER_MCP_MODE=write. quick_log prompt lands
here.
Exit criteria: "log $14 lunch to food" works end to end.
Phase C — entry and lifecycle writes
Add month entry CRUD, budget entry CRUD (dependent on the Roadmap's
inline-edit item for the full surface), lifecycle transitions,
set_debt_target. monthly_review and close_prep prompts land
here.
Each phase is its own issue and its own PR.
Open questions
- Agent activity visibility in the web UI. Nothing today shows
which postings were entered by an agent vs the human. Probably
fine for v1, but if the expectation is "the agent and I share a
ledger", a
sourcecolumn onpostingmight become useful. File a follow-up after phase B. - Fuzzy entry resolution.
quick_logneeds "food" to map to a specific food-section entry. The obvious answer is exact name match first, then section + amount hints, then ask. Implementation detail, but worth a design pass before phase B. - Posting-date validation. The Roadmap has "Constrain posting
dates to the month" as deferred. Until it lands, the MCP's
add_transactionaccepts any date, same as the UI. Once the validator ships, the MCP picks it up for free since it goes through the service layer. - Transport. Stdio is the assumed default for local use. If a
LAN-reachable transport is ever needed, add
sseor streamable HTTP behind an auth gate, not before.