# 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.
