From e94b2ef202707c492dc8d14d25befc1cb8d07249 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 21:04:55 -0600 Subject: [PATCH] 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) --- docs/MCPProposal.md | 317 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 docs/MCPProposal.md diff --git a/docs/MCPProposal.md b/docs/MCPProposal.md new file mode 100644 index 0000000..f0445ff --- /dev/null +++ b/docs/MCPProposal.md @@ -0,0 +1,317 @@ +# 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. -- 2.45.2