Compare commits
8
Commits
auth
...
25a73fe416
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25a73fe416 | ||
|
|
77b42a8758 | ||
|
|
90239baada | ||
|
|
a0c54bbc9f | ||
|
|
9efc4adcd4 | ||
|
|
44de784f70 | ||
|
|
7255c9302c | ||
|
|
50dcaca1e8 |
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Food MCP service (FastMCP — claude.ai connector)
|
||||||
|
After=network.target food.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=openclaw
|
||||||
|
Group=automation
|
||||||
|
WorkingDirectory=/var/lib/food
|
||||||
|
Environment="PATH=/var/lib/food/.venv/bin:/usr/bin"
|
||||||
|
# FOOD_API_TOKEN (the caine DRF token), optional FOOD_API_BASE / FOOD_MCP_PORT.
|
||||||
|
EnvironmentFile=-/var/lib/food/.env
|
||||||
|
ExecStart=/var/lib/food/.venv/bin/python -m mcp_server
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+1
-1
@@ -5,7 +5,7 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=notify
|
Type=notify
|
||||||
User=openclaw
|
User=openclaw
|
||||||
Group=openclaw
|
Group=automation
|
||||||
WorkingDirectory=/var/lib/food
|
WorkingDirectory=/var/lib/food
|
||||||
Environment="PATH=/var/lib/food/.venv/bin:/usr/bin"
|
Environment="PATH=/var/lib/food/.venv/bin:/usr/bin"
|
||||||
Environment="DJANGO_SETTINGS_MODULE=food_project.settings"
|
Environment="DJANGO_SETTINGS_MODULE=food_project.settings"
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# nginx config for food.tomflux.xyz
|
||||||
|
# Adds the MCP location to the existing web-app server block.
|
||||||
|
#
|
||||||
|
# The MCP connector is "authless" from claude.ai's side — the long secret in the
|
||||||
|
# URL path IS the credential. Only that exact prefix is proxied to the FastMCP
|
||||||
|
# service; everything else under /mcp/ falls through to the catch-all 301.
|
||||||
|
# Generate the secret once and keep it out of git, e.g.:
|
||||||
|
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
# then replace REPLACE_WITH_LONG_SECRET below and in the claude.ai connector URL:
|
||||||
|
# https://food.tomflux.xyz/mcp/REPLACE_WITH_LONG_SECRET/
|
||||||
|
|
||||||
|
server {
|
||||||
|
server_name food.tomflux.xyz;
|
||||||
|
|
||||||
|
location /app/ {
|
||||||
|
proxy_pass http://127.0.0.1:8042;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /accounts/ {
|
||||||
|
proxy_pass http://127.0.0.1:8042;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
proxy_pass http://127.0.0.1:8042;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- MCP server (claude.ai cooking-mode connector) ---
|
||||||
|
# Regex so it matches with OR without a trailing slash — claude.ai stores
|
||||||
|
# the connector URL without one. Whatever follows the secret is normalised
|
||||||
|
# onto the FastMCP root (the service serves at "/").
|
||||||
|
location ~ ^/mcp/REPLACE_WITH_LONG_SECRET/?(?<mcp_rest>.*)$ {
|
||||||
|
proxy_pass http://127.0.0.1:8765/$mcp_rest$is_args$args;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off; # MCP Streamable HTTP / SSE
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# OAuth-discovery probes MUST 404, not fall through to the /app/ login
|
||||||
|
# redirect. Otherwise claude.ai's connector flow sees the login page at
|
||||||
|
# /.well-known/oauth-* , thinks the server has an OAuth sign-in service,
|
||||||
|
# tries Dynamic Client Registration, and fails ("Couldn't register with
|
||||||
|
# ... sign-in service"). A 404 here makes it treat the server as authless.
|
||||||
|
location /.well-known/ { return 404; }
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 /app/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Block API and admin from external access (MCP reaches the API over
|
||||||
|
# localhost, so this does not affect it).
|
||||||
|
location /api/ { return 404; }
|
||||||
|
location /admin/ { return 404; }
|
||||||
|
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/dav.jihakuz.xyz/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/dav.jihakuz.xyz/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = food.tomflux.xyz) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
server_name food.tomflux.xyz;
|
||||||
|
listen 80;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
}
|
||||||
+141
-1
@@ -1,8 +1,11 @@
|
|||||||
from django.test import TestCase, Client
|
from django.test import TestCase, Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from kitchen.models import Ingredient, PantryItem, MetaRecipe, Slot, SlotOption
|
from kitchen.models import (
|
||||||
|
Ingredient, PantryItem, MetaRecipe, Slot, SlotOption, Recipe, CookLog,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthTests(TestCase):
|
class AuthTests(TestCase):
|
||||||
@@ -128,3 +131,140 @@ class PageSmokeTests(_AuthedTestCase):
|
|||||||
for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"):
|
for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"):
|
||||||
with self.subTest(page=name):
|
with self.subTest(page=name):
|
||||||
self.assertEqual(self.client.get(reverse(name)).status_code, 200)
|
self.assertEqual(self.client.get(reverse(name)).status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
# --- MCP-facing API (the Phase 3 gaps) ---
|
||||||
|
|
||||||
|
|
||||||
|
class _ApiTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create_user("api", password="pw")
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LogCookApiTests(_ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.mr = MetaRecipe.objects.create(name="Stir Fry", method="fry it")
|
||||||
|
|
||||||
|
def test_accepts_rating(self):
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/log-cook/", {"meta_recipe_id": self.mr.id, "rating": 5}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 201)
|
||||||
|
self.assertEqual(CookLog.objects.get(id=r.data["cook_log_id"]).rating, 5)
|
||||||
|
|
||||||
|
def test_requires_exactly_one_recipe(self):
|
||||||
|
self.assertEqual(self.client.post("/api/log-cook/", {}, format="json").status_code, 400)
|
||||||
|
fixed = Recipe.objects.create(name="Beans", method="heat")
|
||||||
|
both = self.client.post(
|
||||||
|
"/api/log-cook/",
|
||||||
|
{"meta_recipe_id": self.mr.id, "recipe_id": fixed.id},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(both.status_code, 400)
|
||||||
|
|
||||||
|
def test_returns_used_ingredients_and_does_not_mutate_pantry(self):
|
||||||
|
noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
|
||||||
|
item = PantryItem.objects.create(ingredient=noodles, location="cupboard", state="in")
|
||||||
|
slot = Slot.objects.create(meta_recipe=self.mr, name="carb", required=True)
|
||||||
|
SlotOption.objects.create(slot=slot, ingredient=noodles, quantity_per_serving=2, unit="nests")
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/log-cook/",
|
||||||
|
{"meta_recipe_id": self.mr.id, "slot_choices": {"carb": "noodles"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 201)
|
||||||
|
self.assertIn("noodles", [u["ingredient"] for u in r.data["used_ingredients"]])
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertEqual(item.state, "in") # untouched — suggestions only
|
||||||
|
|
||||||
|
|
||||||
|
class SetStateApiTests(_ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
|
||||||
|
self.item = PantryItem.objects.create(
|
||||||
|
ingredient=self.noodles, location="cupboard", state="in"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_set_state_by_name(self):
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/pantry/set-state/", {"ingredient": "noodles", "state": "out"}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.item.refresh_from_db()
|
||||||
|
self.assertEqual(self.item.state, "out")
|
||||||
|
|
||||||
|
def test_invalid_state_rejected(self):
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/pantry/set-state/", {"ingredient": "noodles", "state": "bogus"}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_unknown_ingredient_404(self):
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/pantry/set-state/", {"ingredient": "saffron", "state": "in"}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkAddApiTests(_ApiTestCase):
|
||||||
|
def test_restocks_out_item_without_duplicating(self):
|
||||||
|
eggs = Ingredient.objects.create(name="eggs", default_unit="items")
|
||||||
|
PantryItem.objects.create(ingredient=eggs, location="fridge", state="out")
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/bulk-pantry-add/",
|
||||||
|
{"items": [{"ingredient_name": "eggs", "location": "fridge"}]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 201)
|
||||||
|
rows = PantryItem.objects.filter(ingredient=eggs, location="fridge")
|
||||||
|
self.assertEqual(rows.count(), 1)
|
||||||
|
self.assertEqual(rows.first().state, "in")
|
||||||
|
|
||||||
|
def test_tolerates_null_unit_and_quantity(self):
|
||||||
|
# An MCP client may send unit/quantity as null for unknown items.
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/bulk-pantry-add/",
|
||||||
|
{"items": [{"ingredient_name": "pork mince", "location": "fridge",
|
||||||
|
"unit": None, "quantity": None}]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 201)
|
||||||
|
self.assertTrue(
|
||||||
|
PantryItem.objects.filter(ingredient__name="pork mince", location="fridge").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WhatCanICookApiTests(_ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
|
||||||
|
self.mr = MetaRecipe.objects.create(name="Noodles", method="boil")
|
||||||
|
slot = Slot.objects.create(meta_recipe=self.mr, name="carb", required=True)
|
||||||
|
SlotOption.objects.create(slot=slot, ingredient=self.noodles, quantity_per_serving=2, unit="nests")
|
||||||
|
|
||||||
|
def _status(self, resp):
|
||||||
|
return next(x for x in resp.data["results"] if x["name"] == "Noodles")["status"]
|
||||||
|
|
||||||
|
def test_present_with_no_quantity_is_ready(self):
|
||||||
|
PantryItem.objects.create(ingredient=self.noodles, location="cupboard", state="in")
|
||||||
|
r = self.client.get("/api/what-can-i-cook/")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(self._status(r), "ready")
|
||||||
|
|
||||||
|
def test_out_item_makes_recipe_missing(self):
|
||||||
|
PantryItem.objects.create(ingredient=self.noodles, location="cupboard", state="out")
|
||||||
|
r = self.client.get("/api/what-can-i-cook/")
|
||||||
|
self.assertEqual(self._status(r), "missing")
|
||||||
|
|
||||||
|
|
||||||
|
class PantrySerializerApiTests(_ApiTestCase):
|
||||||
|
def test_state_is_serialized(self):
|
||||||
|
eggs = Ingredient.objects.create(name="eggs", default_unit="items")
|
||||||
|
PantryItem.objects.create(ingredient=eggs, location="fridge", state="low")
|
||||||
|
r = self.client.get("/api/pantry/")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertTrue(any(row.get("state") == "low" for row in r.data))
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ router.register(r"cook-log", views.CookLogViewSet)
|
|||||||
router.register(r"shopping-list", views.ShoppingListItemViewSet)
|
router.register(r"shopping-list", views.ShoppingListItemViewSet)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# Must precede the router include — otherwise the PantryItem detail route
|
||||||
|
# (pantry/<pk>/) captures "set-state" as a pk.
|
||||||
|
path("pantry/set-state/", views.set_pantry_state, name="set-pantry-state"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
path("what-can-i-cook/", views.what_can_i_cook, name="what-can-i-cook"),
|
path("what-can-i-cook/", views.what_can_i_cook, name="what-can-i-cook"),
|
||||||
path("log-cook/", views.log_cook, name="log-cook"),
|
path("log-cook/", views.log_cook, name="log-cook"),
|
||||||
|
|||||||
+95
-88
@@ -2,6 +2,7 @@ import re
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
from rest_framework.decorators import api_view, permission_classes, action
|
from rest_framework.decorators import api_view, permission_classes, action
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
@@ -310,7 +311,9 @@ def what_can_i_cook(request):
|
|||||||
# Build pantry lookup: ingredient_id -> list of {quantity, unit, location, expiry}
|
# Build pantry lookup: ingredient_id -> list of {quantity, unit, location, expiry}
|
||||||
today = date.today()
|
today = date.today()
|
||||||
pantry = {}
|
pantry = {}
|
||||||
for item in PantryItem.objects.select_related("ingredient").filter(quantity__gt=0):
|
for item in PantryItem.objects.select_related("ingredient").exclude(
|
||||||
|
state=PantryItem.State.OUT
|
||||||
|
):
|
||||||
if item.ingredient_id not in pantry:
|
if item.ingredient_id not in pantry:
|
||||||
pantry[item.ingredient_id] = []
|
pantry[item.ingredient_id] = []
|
||||||
is_expired = item.expiry_date and item.expiry_date < today
|
is_expired = item.expiry_date and item.expiry_date < today
|
||||||
@@ -328,10 +331,9 @@ def what_can_i_cook(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def get_pantry_total(ingredient_id):
|
def get_pantry_total(ingredient_id):
|
||||||
"""Total quantity available across all locations."""
|
"""Presence-based availability: having the ingredient (any non-'out'
|
||||||
if ingredient_id not in pantry:
|
item) satisfies a slot regardless of amount. Quantity may be None."""
|
||||||
return Decimal("0")
|
return Decimal("Infinity") if ingredient_id in pantry else Decimal("0")
|
||||||
return sum(p["quantity"] for p in pantry[ingredient_id])
|
|
||||||
|
|
||||||
def get_pantry_warnings(ingredient_id):
|
def get_pantry_warnings(ingredient_id):
|
||||||
"""Get expiry warnings for an ingredient."""
|
"""Get expiry warnings for an ingredient."""
|
||||||
@@ -403,7 +405,7 @@ def what_can_i_cook(request):
|
|||||||
option_info = {
|
option_info = {
|
||||||
"ingredient": option.ingredient.name,
|
"ingredient": option.ingredient.name,
|
||||||
"needed": f"{needed} {option.unit}",
|
"needed": f"{needed} {option.unit}",
|
||||||
"have": f"{available} {option.unit}",
|
"have": "in stock" if available >= needed else "none",
|
||||||
"notes": option.notes,
|
"notes": option.notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,108 +484,65 @@ def what_can_i_cook(request):
|
|||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def log_cook(request):
|
def log_cook(request):
|
||||||
"""
|
"""
|
||||||
Log a meal that was cooked. Optionally deducts ingredients from pantry.
|
Log a meal that was cooked.
|
||||||
|
|
||||||
Body:
|
Body:
|
||||||
{
|
{
|
||||||
"meta_recipe_id": 1, // or "recipe_id": 1
|
"meta_recipe_id": 1, // or "recipe_id": 1 (exactly one)
|
||||||
"slot_choices": {"protein": "pork mince", "carb": "egg noodles"},
|
"slot_choices": {"protein": "pork mince", "carb": "egg noodles"},
|
||||||
"servings": 2,
|
"servings": 2,
|
||||||
"notes": "added extra garlic",
|
"rating": 4, // optional (1-5)
|
||||||
"deduct": true // auto-deduct from pantry
|
"notes": "added extra garlic"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Does NOT change pantry state. Returns `used_ingredients` so the caller can
|
||||||
|
*suggest* marking them low/out — applied separately, after the user
|
||||||
|
confirms, via /api/pantry/set-state/.
|
||||||
"""
|
"""
|
||||||
meta_recipe_id = request.data.get("meta_recipe_id")
|
meta_recipe_id = request.data.get("meta_recipe_id")
|
||||||
recipe_id = request.data.get("recipe_id")
|
recipe_id = request.data.get("recipe_id")
|
||||||
slot_choices = request.data.get("slot_choices", {})
|
slot_choices = request.data.get("slot_choices", {})
|
||||||
servings = int(request.data.get("servings", 2))
|
servings = int(request.data.get("servings", 2))
|
||||||
notes = request.data.get("notes", "")
|
notes = request.data.get("notes", "")
|
||||||
deduct = request.data.get("deduct", False)
|
rating = request.data.get("rating")
|
||||||
|
if rating is not None:
|
||||||
|
rating = int(rating)
|
||||||
|
|
||||||
if not meta_recipe_id and not recipe_id:
|
log = CookLog(
|
||||||
return Response(
|
|
||||||
{"error": "Must provide meta_recipe_id or recipe_id"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create cook log
|
|
||||||
log = CookLog.objects.create(
|
|
||||||
meta_recipe_id=meta_recipe_id,
|
meta_recipe_id=meta_recipe_id,
|
||||||
recipe_id=recipe_id,
|
recipe_id=recipe_id,
|
||||||
slot_choices=slot_choices,
|
slot_choices=slot_choices,
|
||||||
servings=servings,
|
servings=servings,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
|
rating=rating,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
# Enforces the "exactly one of meta_recipe / recipe" rule (CookLog.clean)
|
||||||
|
log.full_clean()
|
||||||
|
except ValidationError as e:
|
||||||
|
return Response({"errors": e.message_dict}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
log.save()
|
||||||
|
|
||||||
deducted = []
|
# Suggest (do NOT apply) which ingredients were used.
|
||||||
|
used = []
|
||||||
if deduct and meta_recipe_id:
|
if meta_recipe_id:
|
||||||
meta = MetaRecipe.objects.prefetch_related(
|
meta = MetaRecipe.objects.prefetch_related(
|
||||||
"slots__options__ingredient", "base_ingredients__ingredient"
|
"slots__options__ingredient", "base_ingredients__ingredient"
|
||||||
).get(id=meta_recipe_id)
|
).get(id=meta_recipe_id)
|
||||||
|
|
||||||
# Deduct base ingredients
|
|
||||||
for base in meta.base_ingredients.all():
|
for base in meta.base_ingredients.all():
|
||||||
amount = base.quantity_per_serving * servings
|
used.append({"ingredient": base.ingredient.name, "via": "base"})
|
||||||
deducted += _deduct_ingredient(base.ingredient, amount, base.unit)
|
|
||||||
|
|
||||||
# Deduct slot choices
|
|
||||||
for slot_name, ingredient_name in slot_choices.items():
|
for slot_name, ingredient_name in slot_choices.items():
|
||||||
try:
|
used.append({"ingredient": ingredient_name, "via": f"slot:{slot_name}"})
|
||||||
slot = meta.slots.get(name=slot_name)
|
elif recipe_id:
|
||||||
option = slot.options.get(ingredient__name=ingredient_name)
|
|
||||||
amount = option.quantity_per_serving * servings
|
|
||||||
deducted += _deduct_ingredient(option.ingredient, amount, option.unit)
|
|
||||||
except (Slot.DoesNotExist, SlotOption.DoesNotExist):
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif deduct and recipe_id:
|
|
||||||
recipe = Recipe.objects.prefetch_related("ingredients__ingredient").get(id=recipe_id)
|
recipe = Recipe.objects.prefetch_related("ingredients__ingredient").get(id=recipe_id)
|
||||||
for ri in recipe.ingredients.all():
|
for ri in recipe.ingredients.all():
|
||||||
amount = ri.quantity * (servings / recipe.servings)
|
used.append({"ingredient": ri.ingredient.name, "via": "ingredient"})
|
||||||
deducted += _deduct_ingredient(ri.ingredient, amount, ri.unit)
|
|
||||||
|
|
||||||
return Response({
|
return Response(
|
||||||
"cook_log_id": log.id,
|
{"cook_log_id": log.id, "used_ingredients": used},
|
||||||
"deducted": deducted,
|
status=status.HTTP_201_CREATED,
|
||||||
}, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
|
|
||||||
def _deduct_ingredient(ingredient, amount, unit):
|
|
||||||
"""Deduct an amount from pantry, using items with the earliest expiry first.
|
|
||||||
|
|
||||||
Ordering is purely by ``expiry_date`` across all locations — location is
|
|
||||||
not part of the ordering.
|
|
||||||
"""
|
|
||||||
remaining = Decimal(str(amount))
|
|
||||||
deducted = []
|
|
||||||
|
|
||||||
# Earliest expiry first, regardless of location (fridge/cupboard/freezer).
|
|
||||||
items = PantryItem.objects.filter(
|
|
||||||
ingredient=ingredient, quantity__gt=0
|
|
||||||
).order_by(
|
|
||||||
"expiry_date",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in items:
|
|
||||||
if remaining <= 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
take = min(item.quantity, remaining)
|
|
||||||
item.quantity -= take
|
|
||||||
item.save(update_fields=["quantity"])
|
|
||||||
remaining -= take
|
|
||||||
|
|
||||||
deducted.append({
|
|
||||||
"ingredient": ingredient.name,
|
|
||||||
"amount": str(take),
|
|
||||||
"unit": unit,
|
|
||||||
"from": item.location,
|
|
||||||
"remaining_in_pantry": str(item.quantity),
|
|
||||||
})
|
|
||||||
|
|
||||||
return deducted
|
|
||||||
|
|
||||||
|
|
||||||
# --- Bulk Pantry Add (Photo Intake) ---
|
# --- Bulk Pantry Add (Photo Intake) ---
|
||||||
|
|
||||||
@@ -615,8 +574,9 @@ def bulk_pantry_add(request):
|
|||||||
|
|
||||||
for item_data in items:
|
for item_data in items:
|
||||||
name = item_data.get("ingredient_name", "").strip()
|
name = item_data.get("ingredient_name", "").strip()
|
||||||
quantity = Decimal(str(item_data.get("quantity", 0)))
|
qty_raw = item_data.get("quantity")
|
||||||
unit = item_data.get("unit", "items")
|
quantity = int(qty_raw) if qty_raw not in (None, "") else None
|
||||||
|
unit = item_data.get("unit") or "items" # tolerate null/empty
|
||||||
location = item_data.get("location", "fridge")
|
location = item_data.get("location", "fridge")
|
||||||
expiry_days = item_data.get("expiry_days") # optional override
|
expiry_days = item_data.get("expiry_days") # optional override
|
||||||
|
|
||||||
@@ -650,27 +610,30 @@ def bulk_pantry_add(request):
|
|||||||
elif ingredient.shelf_life_days:
|
elif ingredient.shelf_life_days:
|
||||||
expiry_date = date.today() + timedelta(days=ingredient.shelf_life_days)
|
expiry_date = date.today() + timedelta(days=ingredient.shelf_life_days)
|
||||||
|
|
||||||
# Check if item already exists in this location
|
# Restock an existing row for this ingredient+location (back to 'in')
|
||||||
|
# rather than creating a duplicate.
|
||||||
existing = PantryItem.objects.filter(
|
existing = PantryItem.objects.filter(
|
||||||
ingredient=ingredient, location=location, quantity__gt=0
|
ingredient=ingredient, location=location
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
existing.quantity += quantity
|
existing.state = PantryItem.State.IN
|
||||||
|
if quantity is not None:
|
||||||
|
existing.quantity = (existing.quantity or 0) + quantity
|
||||||
if expiry_date:
|
if expiry_date:
|
||||||
existing.expiry_date = expiry_date # refresh expiry with new stock
|
existing.expiry_date = expiry_date # refresh expiry with new stock
|
||||||
existing.save()
|
existing.save()
|
||||||
results.append({
|
results.append({
|
||||||
"ingredient": ingredient.name,
|
"ingredient": ingredient.name,
|
||||||
"action": "added_to_existing",
|
"action": "restocked",
|
||||||
"added": str(quantity),
|
"new_total": str(existing.quantity) if existing.quantity is not None else None,
|
||||||
"new_total": str(existing.quantity),
|
"unit": existing.unit,
|
||||||
"unit": unit,
|
|
||||||
"location": location,
|
"location": location,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
PantryItem.objects.create(
|
PantryItem.objects.create(
|
||||||
ingredient=ingredient,
|
ingredient=ingredient,
|
||||||
|
state=PantryItem.State.IN,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
unit=unit,
|
unit=unit,
|
||||||
location=location,
|
location=location,
|
||||||
@@ -680,7 +643,7 @@ def bulk_pantry_add(request):
|
|||||||
results.append({
|
results.append({
|
||||||
"ingredient": ingredient.name,
|
"ingredient": ingredient.name,
|
||||||
"action": "created",
|
"action": "created",
|
||||||
"quantity": str(quantity),
|
"quantity": str(quantity) if quantity is not None else None,
|
||||||
"unit": unit,
|
"unit": unit,
|
||||||
"location": location,
|
"location": location,
|
||||||
"expiry_date": str(expiry_date) if expiry_date else None,
|
"expiry_date": str(expiry_date) if expiry_date else None,
|
||||||
@@ -695,6 +658,50 @@ def _is_known_staple(ingredient):
|
|||||||
return ingredient.name.lower() in staple_names
|
return ingredient.name.lower() in staple_names
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def set_pantry_state(request):
|
||||||
|
"""
|
||||||
|
Set an ingredient's pantry state (in/low/out) by name — the conversational
|
||||||
|
update path ("used the last of the noodles" -> out).
|
||||||
|
|
||||||
|
Body: {"ingredient": "egg noodles", "state": "out", "location": "fridge"}
|
||||||
|
`location` is optional; without it the first matching pantry row is used.
|
||||||
|
If no pantry row exists, one is created so the state can be recorded.
|
||||||
|
"""
|
||||||
|
name = (request.data.get("ingredient") or "").strip()
|
||||||
|
new_state = request.data.get("state", "")
|
||||||
|
location = request.data.get("location")
|
||||||
|
|
||||||
|
if new_state not in PantryItem.State.values:
|
||||||
|
return Response(
|
||||||
|
{"error": f"Invalid state '{new_state}'. Use one of {list(PantryItem.State.values)}."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
ingredient = _find_ingredient(name)
|
||||||
|
if not ingredient:
|
||||||
|
return Response(
|
||||||
|
{"error": f"No ingredient matching '{name}'."},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
qs = PantryItem.objects.filter(ingredient=ingredient)
|
||||||
|
if location:
|
||||||
|
qs = qs.filter(location=location)
|
||||||
|
item = qs.first()
|
||||||
|
if item is None:
|
||||||
|
item = PantryItem(
|
||||||
|
ingredient=ingredient,
|
||||||
|
location=location or PantryItem.Location.FRIDGE,
|
||||||
|
unit=ingredient.default_unit or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
item.state = new_state
|
||||||
|
item.save()
|
||||||
|
return Response(PantryItemSerializer(item).data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
# --- Smart Shopping List Generation ---
|
# --- Smart Shopping List Generation ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# 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.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 `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.tomflux.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.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_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 # 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 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.tomflux.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.tomflux.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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""FastMCP "ai service" for the Food pantry app.
|
||||||
|
|
||||||
|
A standalone process (no Django import) that exposes a small set of MCP tools to
|
||||||
|
claude.ai cooking mode over Streamable HTTP, backed by the Food Django REST API.
|
||||||
|
See mcp.md for the full specification.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .server import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Thin HTTP client around the Food Django REST API.
|
||||||
|
|
||||||
|
Pure HTTP — holds no Django import. Talks to the API over localhost using the
|
||||||
|
`caine` DRF token (Authorization: Token <token>). One method per endpoint the
|
||||||
|
MCP tools need; all errors surface as `FoodApiError` with a readable message.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class FoodApiError(Exception):
|
||||||
|
"""The Django API returned a non-2xx response, or was unreachable."""
|
||||||
|
|
||||||
|
|
||||||
|
class FoodClient:
|
||||||
|
def __init__(self, base=None, token=None, http=None):
|
||||||
|
base = (base or os.environ.get("FOOD_API_BASE", "http://127.0.0.1:8042/api")).rstrip("/")
|
||||||
|
token = token or os.environ.get("FOOD_API_TOKEN", "")
|
||||||
|
# `http` is injectable for tests (e.g. httpx.MockTransport).
|
||||||
|
self._http = http or httpx.Client(
|
||||||
|
base_url=base,
|
||||||
|
headers={"Authorization": f"Token {token}"},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- low level ---
|
||||||
|
|
||||||
|
def _request(self, method, path, **kwargs):
|
||||||
|
try:
|
||||||
|
resp = self._http.request(method, path, **kwargs)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise FoodApiError(f"could not reach the food API: {e}") from e
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise FoodApiError(f"{resp.status_code}: {_safe_json(resp)}")
|
||||||
|
return _safe_json(resp)
|
||||||
|
|
||||||
|
def _get(self, path, params=None):
|
||||||
|
return self._request("GET", path, params=params)
|
||||||
|
|
||||||
|
def _post(self, path, json):
|
||||||
|
return self._request("POST", path, json=json)
|
||||||
|
|
||||||
|
# --- endpoints (one per MCP tool need) ---
|
||||||
|
|
||||||
|
def pantry(self):
|
||||||
|
return self._get("/pantry/")
|
||||||
|
|
||||||
|
def set_state(self, ingredient, state, location=None):
|
||||||
|
body = {"ingredient": ingredient, "state": state}
|
||||||
|
if location:
|
||||||
|
body["location"] = location
|
||||||
|
return self._post("/pantry/set-state/", body)
|
||||||
|
|
||||||
|
def bulk_add(self, items):
|
||||||
|
return self._post("/bulk-pantry-add/", {"items": items})
|
||||||
|
|
||||||
|
def what_can_i_cook(self, servings=2):
|
||||||
|
return self._get("/what-can-i-cook/", {"servings": servings})
|
||||||
|
|
||||||
|
def meta_recipes(self):
|
||||||
|
return self._get("/meta-recipes/")
|
||||||
|
|
||||||
|
def log_cook(self, payload):
|
||||||
|
return self._post("/log-cook/", payload)
|
||||||
|
|
||||||
|
def create_meta_recipe(self, payload):
|
||||||
|
return self._post("/create-meta-recipe/", payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json(resp):
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return resp.text
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""FastMCP tool definitions for the Food pantry app.
|
||||||
|
|
||||||
|
Each tool maps to a Django API call via FoodClient and returns compact,
|
||||||
|
Claude-friendly data (names + states, not raw API dumps). Tool descriptions
|
||||||
|
state *when* to call them — current models reach for tools conservatively.
|
||||||
|
|
||||||
|
Run with `python -m mcp_server` (see __main__.py). FastMCP 2.x.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from .client import FoodClient, FoodApiError
|
||||||
|
|
||||||
|
mcp = FastMCP("food-pantry")
|
||||||
|
_api = FoodClient()
|
||||||
|
|
||||||
|
VALID_STATES = ("in", "low", "out")
|
||||||
|
|
||||||
|
|
||||||
|
def _err(e):
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def get_pantry(location: str | None = None, include_out: bool = False) -> dict:
|
||||||
|
"""List what's in the pantry with each item's state (in / low / out). Call
|
||||||
|
this before suggesting meals or a shopping list so advice is grounded in
|
||||||
|
what's actually in stock. `location` optionally filters to fridge, cupboard,
|
||||||
|
or freezer. 'out' items are omitted unless `include_out` is true."""
|
||||||
|
try:
|
||||||
|
rows = _api.pantry()
|
||||||
|
except FoodApiError as e:
|
||||||
|
return _err(e)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
counts = {"in": 0, "low": 0, "out": 0}
|
||||||
|
for r in rows:
|
||||||
|
state = r.get("state")
|
||||||
|
if state in counts:
|
||||||
|
counts[state] += 1
|
||||||
|
if location and r.get("location") != location:
|
||||||
|
continue
|
||||||
|
if not include_out and state == "out":
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
"name": r.get("ingredient_name"),
|
||||||
|
"state": state,
|
||||||
|
"location": r.get("location"),
|
||||||
|
"quantity": r.get("quantity"),
|
||||||
|
"unit": r.get("unit"),
|
||||||
|
"expiry": r.get("expiry_date"),
|
||||||
|
"staple": r.get("is_staple"),
|
||||||
|
})
|
||||||
|
return {"items": items, "counts": counts}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def set_item_state(ingredient: str, state: str, location: str | None = None) -> dict:
|
||||||
|
"""Mark an ingredient In stock / running Low / Out. Call this when the user
|
||||||
|
says they used up, ran low on, or restocked something. `state` must be one
|
||||||
|
of 'in', 'low', 'out'. `location` is optional (fridge/cupboard/freezer)."""
|
||||||
|
if state not in VALID_STATES:
|
||||||
|
return {"error": f"state must be one of {list(VALID_STATES)}"}
|
||||||
|
try:
|
||||||
|
return _api.set_state(ingredient, state, location)
|
||||||
|
except FoodApiError as e:
|
||||||
|
return _err(e)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def add_to_pantry(items: list[dict]) -> dict:
|
||||||
|
"""Add items the user bought. Each item is {name, location?, quantity?,
|
||||||
|
unit?} — e.g. {"name": "pork mince", "location": "fridge"}. Existing items
|
||||||
|
are restocked (set back to In) rather than duplicated."""
|
||||||
|
payload = []
|
||||||
|
for it in items:
|
||||||
|
entry = {
|
||||||
|
"ingredient_name": it.get("name") or it.get("ingredient_name"),
|
||||||
|
"location": it.get("location", "fridge"),
|
||||||
|
}
|
||||||
|
# Only forward optional fields when set, so the API applies its defaults.
|
||||||
|
if it.get("quantity") is not None:
|
||||||
|
entry["quantity"] = it["quantity"]
|
||||||
|
if it.get("unit"):
|
||||||
|
entry["unit"] = it["unit"]
|
||||||
|
payload.append(entry)
|
||||||
|
try:
|
||||||
|
return _api.bulk_add(payload)
|
||||||
|
except FoodApiError as e:
|
||||||
|
return _err(e)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def what_can_i_cook(servings: int = 2) -> dict:
|
||||||
|
"""Match the pantry against the meta-recipe templates. Returns each recipe
|
||||||
|
as ready / partial / missing with per-slot availability — use it to suggest
|
||||||
|
what the user can make right now."""
|
||||||
|
try:
|
||||||
|
return _api.what_can_i_cook(servings)
|
||||||
|
except FoodApiError as e:
|
||||||
|
return _err(e)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def get_recipes() -> dict:
|
||||||
|
"""The meta-recipe templates with their swappable slot options. Use this to
|
||||||
|
suggest substitutions — each slot lists the ingredients that can fill it
|
||||||
|
(e.g. a protein slot accepts pork mince OR chicken)."""
|
||||||
|
try:
|
||||||
|
return {"meta_recipes": _api.meta_recipes()}
|
||||||
|
except FoodApiError as e:
|
||||||
|
return _err(e)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def log_cook(
|
||||||
|
meta_recipe_id: int | None = None,
|
||||||
|
recipe_id: int | None = None,
|
||||||
|
slot_choices: dict | None = None,
|
||||||
|
servings: int = 2,
|
||||||
|
rating: int | None = None,
|
||||||
|
notes: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Record a cooked meal (and an optional 1-5 rating). Provide exactly one of
|
||||||
|
`meta_recipe_id` or `recipe_id`. Returns `used_ingredients` as SUGGESTIONS
|
||||||
|
only — the pantry is NOT changed. Confirm with the user, then call
|
||||||
|
`set_item_state` for each ingredient they agree to mark low or out."""
|
||||||
|
payload = {
|
||||||
|
"meta_recipe_id": meta_recipe_id,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"slot_choices": slot_choices or {},
|
||||||
|
"servings": servings,
|
||||||
|
"notes": notes,
|
||||||
|
}
|
||||||
|
if rating is not None:
|
||||||
|
payload["rating"] = rating
|
||||||
|
try:
|
||||||
|
return _api.log_cook(payload)
|
||||||
|
except FoodApiError as e:
|
||||||
|
return _err(e)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def create_meta_recipe(recipe: dict) -> dict:
|
||||||
|
"""Save a meta-recipe the user brainstormed in the chat. `recipe` is the full
|
||||||
|
template:
|
||||||
|
|
||||||
|
{name, method, prep_time_mins?, cook_time_mins?, default_servings?,
|
||||||
|
gear_needed?, tags?,
|
||||||
|
slots: [{name, required?, max_choices?,
|
||||||
|
options: [{ingredient_name, quantity_per_serving, unit, tags?}]}],
|
||||||
|
base_ingredients: [{ingredient_name, quantity_per_serving, unit}]}
|
||||||
|
|
||||||
|
Pass an `id` to update an existing template (it rebuilds slots/bases).
|
||||||
|
Unknown ingredients are auto-created."""
|
||||||
|
try:
|
||||||
|
return _api.create_meta_recipe(recipe)
|
||||||
|
except FoodApiError as e:
|
||||||
|
return _err(e)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
host = os.environ.get("FOOD_MCP_HOST", "127.0.0.1")
|
||||||
|
port = int(os.environ.get("FOOD_MCP_PORT", "8765"))
|
||||||
|
# Streamable HTTP at the root path; nginx maps /mcp/<secret>/ -> here.
|
||||||
|
mcp.run(transport="http", host=host, port=port, path="/")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for the FoodClient HTTP layer, using httpx.MockTransport (no network,
|
||||||
|
no Django, no fastmcp). Run with: python -m unittest mcp_server.tests
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from mcp_server.client import FoodClient, FoodApiError
|
||||||
|
|
||||||
|
|
||||||
|
def make_client(handler):
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
http = httpx.Client(
|
||||||
|
transport=transport,
|
||||||
|
base_url="http://api.test/api",
|
||||||
|
headers={"Authorization": "Token x"},
|
||||||
|
)
|
||||||
|
return FoodClient(http=http)
|
||||||
|
|
||||||
|
|
||||||
|
class FoodClientTests(unittest.TestCase):
|
||||||
|
def test_set_state_posts_expected_body(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
captured["path"] = request.url.path
|
||||||
|
captured["body"] = json.loads(request.content)
|
||||||
|
return httpx.Response(200, json={"state": "out"})
|
||||||
|
|
||||||
|
out = make_client(handler).set_state("noodles", "out", location="cupboard")
|
||||||
|
self.assertEqual(captured["path"], "/api/pantry/set-state/")
|
||||||
|
self.assertEqual(
|
||||||
|
captured["body"],
|
||||||
|
{"ingredient": "noodles", "state": "out", "location": "cupboard"},
|
||||||
|
)
|
||||||
|
self.assertEqual(out["state"], "out")
|
||||||
|
|
||||||
|
def test_set_state_omits_location_when_absent(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
captured["body"] = json.loads(request.content)
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
|
||||||
|
make_client(handler).set_state("eggs", "in")
|
||||||
|
self.assertNotIn("location", captured["body"])
|
||||||
|
|
||||||
|
def test_bulk_add_wraps_items(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
captured["body"] = json.loads(request.content)
|
||||||
|
return httpx.Response(201, json={"added": 1})
|
||||||
|
|
||||||
|
make_client(handler).bulk_add([{"ingredient_name": "eggs"}])
|
||||||
|
self.assertEqual(captured["body"], {"items": [{"ingredient_name": "eggs"}]})
|
||||||
|
|
||||||
|
def test_what_can_i_cook_passes_servings(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
captured["servings"] = request.url.params.get("servings")
|
||||||
|
return httpx.Response(200, json={"results": []})
|
||||||
|
|
||||||
|
make_client(handler).what_can_i_cook(servings=4)
|
||||||
|
self.assertEqual(captured["servings"], "4")
|
||||||
|
|
||||||
|
def test_error_response_raises(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(404, json={"error": "nope"})
|
||||||
|
|
||||||
|
with self.assertRaises(FoodApiError):
|
||||||
|
make_client(handler).set_state("x", "out")
|
||||||
|
|
||||||
|
def test_unreachable_api_raises(self):
|
||||||
|
def handler(request):
|
||||||
|
raise httpx.ConnectError("boom")
|
||||||
|
|
||||||
|
with self.assertRaises(FoodApiError):
|
||||||
|
make_client(handler).pantry()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -14,3 +14,11 @@ dependencies = [
|
|||||||
|
|
||||||
# No [build-system]: this is an application, not an installable package, so uv
|
# No [build-system]: this is an application, not an installable package, so uv
|
||||||
# treats it as a virtual project and won't try to build/install it.
|
# treats it as a virtual project and won't try to build/install it.
|
||||||
|
|
||||||
|
# Optional deps for the MCP "ai service" (deploy with `uv sync --group mcp`).
|
||||||
|
# Kept out of the default set so the Django service stays lean.
|
||||||
|
[dependency-groups]
|
||||||
|
mcp = [
|
||||||
|
"fastmcp>=2",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user