Files
food/mcp.md
T
Tom FluxandClaude Opus 4.8 50dcaca1e8 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>
2026-06-23 21:02:26 +01:00

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 (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 /mcpproxy_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.