1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
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.
|