A standalone MCP server (no Django import) exposing 7 tools over Streamable HTTP, backed by the Django REST API via the caine token. - mcp_server/: client.py (httpx wrapper over /api/), server.py (the tools + FastMCP app + main), __main__.py, tests.py. - Tools: get_pantry, set_item_state, add_to_pantry, what_can_i_cook, get_recipes, log_cook (suggests, never mutates), create_meta_recipe (brainstorm -> commit). Each description says when to call it. - deploy/food-mcp.service: systemd unit (own process, runs .venv python -m mcp_server, loads /var/lib/food/.env). - deploy/food.tomflux.xyz.nginx: current config + an authless /mcp/<secret>/ location proxying to 127.0.0.1:8765 (the URL secret is the credential; SSE-friendly buffering/timeout). - pyproject: [dependency-groups] mcp = [fastmcp, httpx]; deploy with `uv sync --group mcp`. Verified: 7 tools register on fastmcp 3.x, run() accepts transport/ host/port/path, 6 FoodClient unit tests pass (httpx MockTransport). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 KiB
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 (thestatefield) and auth phase.Decisions locked in
plan.md §3andrequirements.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 atfood.tomflux.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.tomflux.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
caineDRF token, over127.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.tomflux.xyz,location /mcp→proxy_pass http://127.0.0.1:8765. TLS via the existing Let's Encrypt setup. Note SSE/streaming needsproxy_buffering off;and a longproxy_read_timeouton that location. - Process: its own systemd unit
food-mcp.servicealongsidefood.service, runs asopenclaw,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.tomflux.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_stateresolves the name via the existing alias-awarehelpers.find_ingredient. If a name is ambiguous or unknown, the tool returns a clear error listing close matches so Claude can ask Tom.get_recipesis 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_statewhen the user says they used up or ran low on something"), not just what it does. create_meta_recipemaps 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 anidupdates an existing template (it rebuilds slots/bases). This +log_cookare 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.
log_cookacceptsrating, returns suggestions, mutates nothing. Today the endpoint ignoresratingthough the model has the field (research.md §5). Addrating, and route creation through model validation so the "exactly one recipe link" rule is enforced (currently bypassed byobjects.create). Remove the old auto-deduct path — instead returnused_ingredients(base- slot choices) as a suggestion set. Pantry state is only ever changed by
set_item_stateafter Tom confirms (presence-based pantry; never silently mutate from a cook log).
- slot choices) as a suggestion set. Pantry state is only ever changed by
- New
POST /api/pantry/set-state/—{ingredient, location?, state}, resolves by name/alias, sets state, returns the item. Backsset_item_state. (Alternative: have the MCPGET /api/pantry/thenPATCH /api/pantry/<id>/— but a by-name endpoint is cleaner and reusable.) - Make the API matcher presence-based.
what_can_i_cookinviews.pystill 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. bulk-pantry-addrestocks instead of duplicating. It currently matches existing rows byquantity__gt=0, so adding an item that's marked Out creates a duplicate. Match by ingredient+location and setstate="in"(mirror what the webpantry_addnow does).- 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 # entrypoint: from .server import main; main()
server.py # FastMCP app + the @mcp.tool definitions (§5) + main()
client.py # thin httpx client around the Django API (caine token)
tests.py # FoodClient tests via httpx.MockTransport (no network)
main() runs streamable-HTTP at path / on 127.0.0.1:8765
(mcp.run(transport="http", host, port, path="/")); nginx maps
/mcp/<secret>/ → that root. Client tests run with
python -m unittest mcp_server.tests (needs the mcp dep group).
- Deps via a uv group so they only install where needed:
[dependency-groups] mcp = ["fastmcp", "httpx"]. Deploy withuv sync --group mcpon 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)
uv sync --group mcpon the box.- Add the three env vars to
/var/lib/food/.env; generate the bearer token. - Install
deploy/food-mcp.service,daemon-reload, start, enable. - Add the
location /mcpblock to thefood.tomflux.xyznginx config (proxy_buffering off, long read timeout),nginx -t, reload. - In claude.ai, add a custom connector pointing at the secret URL
https://food.tomflux.xyz/mcp/<secret>/(authless connector — see §4). - Smoke test: in cooking mode, "what's in my pantry?" → Claude calls
get_pantryand 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
outitems unless asked — "what do I have" shouldn't list everything you're missing.
11. Testing
- Django side: unit tests for the new
set-stateendpoint,log_cookrating, presence-based API matcher, and bulk-add restock (extendkitchen/tests.py). - MCP side: test
client.pyagainst 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 (orcurlwith 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
- 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.
- Cook-log nudge — RESOLVED:
log_cookdoes not auto-change pantry state. It returnsused_ingredientsso Claude can suggest marking them Low/Out; Tom confirms, and the change goes throughset_item_state. Never silently mutate the pantry from a cook log. - 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
- Close the Django API gaps (§6) on a branch — small, test-covered, mergeable on their own.
- Wire the URL-secret gate in nginx (§4) —
location /mcp/<secret>/proxies, everything else 404s. - Build the FastMCP service (§5, §7) + systemd unit.
- 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.