Add MCP server spec; mark phases 1-2 done in plan
mcp.md specs the Phase 3 FastMCP "ai service": tools, the Django API gaps to close, authless-connector + URL-secret auth, and the brainstorm->commit path for meta-recipes and cook logs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ba1f792826
commit
50dcaca1e8
@@ -0,0 +1,262 @@
|
|||||||
|
# MCP Server — Specification
|
||||||
|
|
||||||
|
> Written 2026-06-23. Phase 3 of `plan.md`: let claude.ai "cooking mode" read
|
||||||
|
> and update the pantry directly, so Tom stops reciting his inventory and pantry
|
||||||
|
> upkeep becomes conversational. Builds on the shipped pantry redesign (the
|
||||||
|
> `state` field) and auth phase.
|
||||||
|
>
|
||||||
|
> Decisions locked in `plan.md §3` and `requirements.md §7`: a **separate
|
||||||
|
> FastMCP "ai service"** (a pattern Tom already runs elsewhere) that talks to
|
||||||
|
> the existing Django REST API, hosted on the same box at
|
||||||
|
> **`food.jihakuz.xyz/mcp`**, behind nginx, **bearer-token** auth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
One sentence: **Claude, in claude.ai cooking mode, can see what's actually in
|
||||||
|
the pantry and write changes back — without Tom typing his inventory.**
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
- Read the pantry (what's in / low / out, by location).
|
||||||
|
- Update item state conversationally ("used the last of the noodles" → Out).
|
||||||
|
- Add items ("bought eggs and pork mince").
|
||||||
|
- See what's cookable (the meta-recipe matcher) and the recipe templates
|
||||||
|
(which double as a substitution table).
|
||||||
|
- **Commit ideas back** — create/update a meta-recipe Tom brainstormed in the
|
||||||
|
chat, and log a cook (with rating). This is a primary workflow: Tom thinks out
|
||||||
|
loud in the LLM, then wants it saved without re-entering it by hand.
|
||||||
|
|
||||||
|
Out of scope (for now): the open-ended "this isn't on the shelf, what else
|
||||||
|
works?" reasoning is *Claude's* job given the recipe/slot data — the MCP just
|
||||||
|
serves the data; no substitution engine here. Fixed (non-template) recipes stay
|
||||||
|
admin-managed; the brainstorm→commit path targets meta-recipes.
|
||||||
|
|
||||||
|
**Why the cooking blocks still work:** MCP only gives Claude *tools/data*. It
|
||||||
|
doesn't change how claude.ai renders cooking mode. Claude pulls real pantry
|
||||||
|
contents through these tools and still produces its normal formatted cooking
|
||||||
|
blocks — now grounded instead of guessed.
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
claude.ai (cooking mode)
|
||||||
|
│ Streamable HTTP, secret in the URL path
|
||||||
|
▼
|
||||||
|
nginx food.jihakuz.xyz/mcp (TLS, public)
|
||||||
|
│ proxy_pass 127.0.0.1:8765
|
||||||
|
▼
|
||||||
|
FastMCP "ai service" (food-mcp.service, localhost only)
|
||||||
|
│ HTTP + DRF token (the `caine` token)
|
||||||
|
▼
|
||||||
|
Django REST API 127.0.0.1:8042/api/ (localhost — bypasses the
|
||||||
|
│ external /api/ nginx block)
|
||||||
|
▼
|
||||||
|
SQLite
|
||||||
|
```
|
||||||
|
|
||||||
|
Three trust hops, three credentials:
|
||||||
|
- **claude.ai → MCP**: a long random **secret in the connector URL path**
|
||||||
|
(see §4). This endpoint is internet-exposed, so that secret is the only thing
|
||||||
|
protecting it.
|
||||||
|
- **MCP → Django API**: the existing `caine` DRF token, over `127.0.0.1` — so
|
||||||
|
it reaches Django directly and isn't subject to nginx's external `/api/`
|
||||||
|
block.
|
||||||
|
- **Django → DB**: unchanged.
|
||||||
|
|
||||||
|
The MCP service is **inbound-only from claude.ai** and **outbound-only to
|
||||||
|
localhost Django**. It holds no database connection of its own.
|
||||||
|
|
||||||
|
## 3. Transport & hosting
|
||||||
|
|
||||||
|
- **Transport:** Streamable HTTP (the remote-MCP transport claude.ai connectors
|
||||||
|
use). FastMCP served over HTTP, bound to `127.0.0.1:8765`.
|
||||||
|
- **Public route:** nginx on `food.jihakuz.xyz`, `location /mcp` →
|
||||||
|
`proxy_pass http://127.0.0.1:8765`. TLS via the existing Let's Encrypt setup.
|
||||||
|
Note SSE/streaming needs `proxy_buffering off;` and a long
|
||||||
|
`proxy_read_timeout` on that location.
|
||||||
|
- **Process:** its own systemd unit `food-mcp.service` alongside `food.service`,
|
||||||
|
runs as `openclaw`, `Restart=on-failure`.
|
||||||
|
|
||||||
|
## 4. Auth
|
||||||
|
|
||||||
|
**Resolved (checked against Anthropic's connector docs, June 2026):** claude.ai
|
||||||
|
custom connectors authenticate a remote MCP server one of two ways — **OAuth**
|
||||||
|
(the server must support Dynamic Client Registration; claude.ai runs the
|
||||||
|
handshake) or **authless** (no auth). The "Add custom connector" dialog only
|
||||||
|
asks for the server **URL**; OAuth client id/secret are optional "Advanced
|
||||||
|
settings". There is **no field to paste a static bearer token or custom
|
||||||
|
header**, so the original bearer-header plan isn't directly supported. Two ways
|
||||||
|
to honour the "tight secret, single user" intent:
|
||||||
|
|
||||||
|
- **Recommended — authless connector + secret in the URL path.** Register the
|
||||||
|
connector URL as `https://food.jihakuz.xyz/mcp/<long-random-secret>/`. To
|
||||||
|
claude.ai it's an authless server; in reality nginx proxies *only* that exact
|
||||||
|
secret prefix to the FastMCP service and 404s everything else. The URL **is**
|
||||||
|
the bearer-equivalent — it matches Tom's "a tight enough secret, I'm the only
|
||||||
|
user" call with zero OAuth machinery. Treat the URL as the secret: TLS only,
|
||||||
|
keep it out of git, rotate by changing the path.
|
||||||
|
- **Heavier but properly revocable — OAuth with Dynamic Client Registration.**
|
||||||
|
FastMCP can front an OAuth provider. Correct and per-grant revocable, but real
|
||||||
|
work for a single-user app. Reach for it only if the URL-secret feels too
|
||||||
|
loose.
|
||||||
|
|
||||||
|
To confirm first-hand: claude.ai → Settings → Connectors → **Add custom
|
||||||
|
connector** shows a single URL field (OAuth fields under "Advanced settings").
|
||||||
|
|
||||||
|
**MCP → Django** is unchanged: the `caine` DRF token in an `Authorization:
|
||||||
|
Token …` header, over `127.0.0.1`.
|
||||||
|
|
||||||
|
## 5. Tools
|
||||||
|
|
||||||
|
Designed for how Claude reasons in conversation — **by ingredient name, not DB
|
||||||
|
id**. Each maps to the existing API (with the small additions in §6).
|
||||||
|
|
||||||
|
| Tool | Purpose | Params | Returns | Backing call |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `get_pantry` | "What do I have?" | `location?` (fridge/cupboard/freezer), `include_out?` (default false) | items: `{name, state, location, quantity?, unit, expiry?, is_staple}` grouped by location, plus counts | `GET /api/pantry/` |
|
||||||
|
| `set_item_state` | The conversational update — "used the last of the noodles" | `ingredient` (name), `state` (in/low/out), `location?` | the updated item | **new** `POST /api/pantry/set-state/` (§6) |
|
||||||
|
| `add_to_pantry` | "I bought eggs and pork mince" | `items: [{name, location?, quantity?, unit?}]` | per-item result (created / restocked) | `POST /api/bulk-pantry-add/` |
|
||||||
|
| `what_can_i_cook` | Meal options grounded in stock | `servings?` | meta-recipes with `ready/partial/missing` + per-slot availability + expiry warnings | `GET /api/what-can-i-cook/` |
|
||||||
|
| `get_recipes` | The templates + their slot options (the substitution table) | — | meta-recipes with slots, options, base ingredients | `GET /api/meta-recipes/` |
|
||||||
|
| `log_cook` | Record a cooked meal + rating — the cook-log commit path | `meta_recipe` or `recipe`, `slot_choices?`, `servings?`, `rating?`, `notes?` | cook-log id + `used_ingredients` (a suggestion set to confirm — **no** auto state change) | `POST /api/log-cook/` (§6) |
|
||||||
|
| `create_meta_recipe` | Commit a meta-recipe brainstormed in the chat (create or update) | nested template: `name`, `method`, `slots:[{name, options:[{ingredient, qty, unit}]}]`, `base_ingredients:[]` (+ `id` to update) | the saved recipe + any auto-created ingredients | `POST`/`PUT /api/create-meta-recipe/` (already exists) |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `set_item_state` resolves the name via the existing alias-aware
|
||||||
|
`helpers.find_ingredient`. If a name is ambiguous or unknown, the tool returns
|
||||||
|
a clear error listing close matches so Claude can ask Tom.
|
||||||
|
- `get_recipes` is what makes Claude useful for substitutions: a protein slot
|
||||||
|
already lists "pork mince OR chicken", so Claude can suggest swaps from real
|
||||||
|
data before reaching for general world knowledge.
|
||||||
|
- Keep the tool set **small and well-described** — current Opus models reach for
|
||||||
|
tools conservatively, so each tool's description states *when* to call it
|
||||||
|
("Call `set_item_state` when the user says they used up or ran low on
|
||||||
|
something"), not just what it does.
|
||||||
|
- `create_meta_recipe` maps to the existing nested create/update endpoint — **no
|
||||||
|
Django change needed**. Claude assembles the slots/options from the brainstorm;
|
||||||
|
the endpoint auto-creates unknown ingredients. Passing an `id` updates an
|
||||||
|
existing template (it rebuilds slots/bases). This + `log_cook` are the
|
||||||
|
brainstorm→commit workflow Tom asked for.
|
||||||
|
|
||||||
|
## 6. Django API changes required (the gaps to close)
|
||||||
|
|
||||||
|
These are small, live on the Django side, and are the "make the API
|
||||||
|
MCP-ready" work. Each ships with a test.
|
||||||
|
|
||||||
|
1. **`log_cook` accepts `rating`, returns suggestions, mutates nothing.** Today
|
||||||
|
the endpoint ignores `rating` though the model has the field (research.md §5).
|
||||||
|
Add `rating`, and route creation through model validation so the "exactly one
|
||||||
|
recipe link" rule is enforced (currently bypassed by `objects.create`).
|
||||||
|
**Remove the old auto-deduct path** — instead return `used_ingredients` (base
|
||||||
|
+ slot choices) as a suggestion set. Pantry state is only ever changed by
|
||||||
|
`set_item_state` after Tom confirms (presence-based pantry; never silently
|
||||||
|
mutate from a cook log).
|
||||||
|
2. **New `POST /api/pantry/set-state/`** — `{ingredient, location?, state}`,
|
||||||
|
resolves by name/alias, sets state, returns the item. Backs `set_item_state`.
|
||||||
|
(Alternative: have the MCP `GET /api/pantry/` then `PATCH /api/pantry/<id>/`
|
||||||
|
— but a by-name endpoint is cleaner and reusable.)
|
||||||
|
3. **Make the API matcher presence-based.** `what_can_i_cook` in `views.py`
|
||||||
|
still compares quantities; the web matcher was already moved to presence
|
||||||
|
(have-it beats have-enough). Apply the same change so the MCP and web agree.
|
||||||
|
4. **`bulk-pantry-add` restocks instead of duplicating.** It currently matches
|
||||||
|
existing rows by `quantity__gt=0`, so adding an item that's marked Out
|
||||||
|
creates a duplicate. Match by ingredient+location and set `state="in"`
|
||||||
|
(mirror what the web `pantry_add` now does).
|
||||||
|
5. **Pantry serializer exposes `state`** — already true (`fields = "__all__"`),
|
||||||
|
just confirm it in a test so it can't regress.
|
||||||
|
|
||||||
|
*Note:* `create_meta_recipe` needs **no** Django change — `POST/PUT
|
||||||
|
/api/create-meta-recipe/` already does nested create/update with ingredient
|
||||||
|
auto-creation. The brainstorm→commit path is otherwise pure MCP plumbing over
|
||||||
|
endpoints that exist; the only write-path gap is `log_cook`'s rating (item 1).
|
||||||
|
|
||||||
|
## 7. Project layout & dependencies
|
||||||
|
|
||||||
|
A small package in this repo, its own process — not bolted into Django:
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp_server/
|
||||||
|
__init__.py
|
||||||
|
__main__.py # FastMCP app; runs streamable-HTTP on 127.0.0.1:8765
|
||||||
|
client.py # thin httpx client around the Django API (caine token)
|
||||||
|
tools.py # the @mcp.tool definitions from §5
|
||||||
|
```
|
||||||
|
|
||||||
|
- Deps via a uv group so they only install where needed:
|
||||||
|
`[dependency-groups] mcp = ["fastmcp", "httpx"]`. Deploy with
|
||||||
|
`uv sync --group mcp` on the box that runs the service.
|
||||||
|
- The service holds no Django import — it's a pure HTTP client of `/api/`.
|
||||||
|
Keeps the two deployables decoupled (Tom's "ai service" pattern).
|
||||||
|
|
||||||
|
## 8. Config (env, in `/var/lib/food/.env`)
|
||||||
|
|
||||||
|
| Var | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `FOOD_MCP_URL_SECRET` | the secret path segment nginx requires on `/mcp/<secret>/` (see §4) |
|
||||||
|
| `FOOD_API_BASE` | `http://127.0.0.1:8042/api` |
|
||||||
|
| `FOOD_API_TOKEN` | the `caine` DRF token |
|
||||||
|
|
||||||
|
All out of git; the systemd unit loads the same `.env` the Django service uses.
|
||||||
|
|
||||||
|
## 9. Deployment (sketch)
|
||||||
|
|
||||||
|
1. `uv sync --group mcp` on the box.
|
||||||
|
2. Add the three env vars to `/var/lib/food/.env`; generate the bearer token.
|
||||||
|
3. Install `deploy/food-mcp.service`, `daemon-reload`, start, enable.
|
||||||
|
4. Add the `location /mcp` block to the `food.jihakuz.xyz` nginx config
|
||||||
|
(`proxy_buffering off`, long read timeout), `nginx -t`, reload.
|
||||||
|
5. In claude.ai, add a custom connector pointing at the **secret URL**
|
||||||
|
`https://food.jihakuz.xyz/mcp/<secret>/` (authless connector — see §4).
|
||||||
|
6. Smoke test: in cooking mode, "what's in my pantry?" → Claude calls
|
||||||
|
`get_pantry` and lists real items.
|
||||||
|
|
||||||
|
## 10. Conventions & error handling
|
||||||
|
|
||||||
|
- Tools return **structured, compact** results (names + states), not raw API
|
||||||
|
JSON dumps — keep token cost down and make Claude's job easy.
|
||||||
|
- On a Django API error, the tool returns a short plain-language error
|
||||||
|
(`is_error`) so Claude can relay or ask, never a stack trace.
|
||||||
|
- All writes are idempotent-ish: setting state to its current value is a no-op;
|
||||||
|
adding an existing item restocks rather than duplicates.
|
||||||
|
- Read tools default to excluding `out` items unless asked — "what do I have"
|
||||||
|
shouldn't list everything you're missing.
|
||||||
|
|
||||||
|
## 11. Testing
|
||||||
|
|
||||||
|
- **Django side:** unit tests for the new `set-state` endpoint, `log_cook`
|
||||||
|
rating, presence-based API matcher, and bulk-add restock (extend
|
||||||
|
`kitchen/tests.py`).
|
||||||
|
- **MCP side:** test `client.py` against a stub API; test each tool maps the
|
||||||
|
right call and shapes results. The MCP server itself can be smoke-tested by
|
||||||
|
pointing a local MCP client (or `curl` with the bearer) at it.
|
||||||
|
|
||||||
|
!! i should also be able to run the stack on this box and have you auth with it for testing
|
||||||
|
|
||||||
|
## 12. Open decisions to confirm before building
|
||||||
|
|
||||||
|
1. **claude.ai connector auth** — RESOLVED (§4): no static-bearer field exists,
|
||||||
|
so use an **authless connector + secret in the URL path** (recommended), or
|
||||||
|
OAuth/DCR if that feels too loose.
|
||||||
|
2. **Cook-log nudge — RESOLVED:** `log_cook` does **not** auto-change pantry
|
||||||
|
state. It returns `used_ingredients` so Claude can *suggest* marking them
|
||||||
|
Low/Out; Tom confirms, and the change goes through `set_item_state`. Never
|
||||||
|
silently mutate the pantry from a cook log.
|
||||||
|
3. **Bearer in nginx vs app** — check the token in nginx, the FastMCP app, or
|
||||||
|
both? Recommended: app (so it's in one place with the tools); nginx optional.
|
||||||
|
|
||||||
|
## Build order
|
||||||
|
|
||||||
|
1. Close the Django API gaps (§6) on a branch — small, test-covered, mergeable
|
||||||
|
on their own.
|
||||||
|
2. Wire the URL-secret gate in nginx (§4) — `location /mcp/<secret>/` proxies,
|
||||||
|
everything else 404s.
|
||||||
|
3. Build the FastMCP service (§5, §7) + systemd unit.
|
||||||
|
4. nginx `/mcp` + TLS, register the connector, smoke test.
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
|
||||||
|
In claude.ai cooking mode, Tom can say "what can I make tonight?" and Claude
|
||||||
|
lists options grounded in the actual pantry; and "I used the last of the
|
||||||
|
noodles" updates the pantry — with no manual inventory typing, and the usual
|
||||||
|
cooking blocks intact.
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
| Phase | Goal | Risk | Depends on |
|
| Phase | Goal | Risk | Depends on |
|
||||||
|------|------|------|-----------|
|
|------|------|------|-----------|
|
||||||
| 0 | ✅ Commit the simplify baseline — **done** | none | — |
|
| 0 | ✅ Commit the simplify baseline — **done** | none | — |
|
||||||
| 1 | Pantry: mobile-first, In/Low/Out, fast add | medium (model + UI) | 0 |
|
| 1 | ✅ Pantry: mobile-first, In/Low/Out, fast add — **done & deployed** | medium (model + UI) | 0 |
|
||||||
| 2 | Auth (login-once) + prod baseline | low | — (can run parallel to 1) |
|
| 2 | ✅ Auth (login-once) + prod baseline — **done & deployed** | low | — |
|
||||||
| 3 | MCP server — read + update pantry from claude.ai | medium | 1, 2 |
|
| 3 | MCP server — read + update pantry from claude.ai (**specced → `mcp.md`**) | medium | 1, 2 |
|
||||||
|
|
||||||
Deferred (not in this plan): web what-can-i-cook rework, §4.1 substitutions,
|
Deferred (not in this plan): web what-can-i-cook rework, §4.1 substitutions,
|
||||||
shopping flow rethink, web cook-logging. They stay roughly working but are not
|
shopping flow rethink, web cook-logging. They stay roughly working but are not
|
||||||
|
|||||||
Reference in New Issue
Block a user