# 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: ```toml [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.