quartermaster/docs/MCPProposal.md
archeious e94b2ef202 Add MCP proposal document
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>
2026-04-17 21:04:55 -06:00

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, notes
  • debt_target — singleton pointer to one debt_minimum entry
  • month — year_month, state, activated_at, closed_at
  • month_entry — id, month_id, section, name, planned, notes, origin_name, origin_planned, source_entry_id. applied is a derived property (sum of postings).
  • posting — id, month_entry_id, occurred_on, amount, description, payee
  • month_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 read mode with read_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

  1. 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 source column on posting might become useful. File a follow-up after phase B.
  2. Fuzzy entry resolution. quick_log needs "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.
  3. Posting-date validation. The Roadmap has "Constrain posting dates to the month" as deferred. Until it lands, the MCP's add_transaction accepts any date, same as the UI. Once the validator ships, the MCP picks it up for free since it goes through the service layer.
  4. Transport. Stdio is the assumed default for local use. If a LAN-reachable transport is ever needed, add sse or streamable HTTP behind an auth gate, not before.