Add MCP proposal document #24
1 changed files with 317 additions and 0 deletions
317
docs/MCPProposal.md
Normal file
317
docs/MCPProposal.md
Normal file
|
|
@ -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.
|
||||||
Loading…
Reference in a new issue