Initial commit: Mealie MCP server
Ports a working Hermes agent skill to an MCP server. The skill's value was not its Mealie endpoints but ~25 operational rules found by running imports against the live instance; those are now code with tests rather than prompt text. The server owns deterministic mechanics — auth, the non-default User-Agent Cloudflare requires, the `extension` field on cover uploads, PATCH instead of the 500-ing bulk-action endpoints, decimal-comma normalization, and restoring Swedish display text after parsing. Language and taste judgement stay with the model, fed by the four reference notes shipped as MCP resources. verify_recipe runs the finished-import definition as code so an agent cannot report success on a recipe with an English ingredient line, a missing cover, or ingredients still in the "Click Parse" state. Home Assistant is optional and isolated; the Obsidian meal plan is out of scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Mealie MCP server."""
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Text normalization for Swedish recipe content.
|
||||
|
||||
Pure functions, no I/O. These encode pitfalls verified against Fredrik's live
|
||||
Mealie instance — see resources/import-pitfalls.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
#: Campaign/navigation suffixes seen appended to imported titles, notably from Köket.
|
||||
TITLE_JUNK_SUFFIXES = (
|
||||
"- se & gör",
|
||||
"- se och gör",
|
||||
"| Köket.se",
|
||||
"| Köket",
|
||||
"- Recept",
|
||||
"- recept",
|
||||
)
|
||||
|
||||
#: Placeholder text Mealie writes when a scrape fails to find real content.
|
||||
EXTRACTION_FAILURE_MARKERS = (
|
||||
"Could not detect ingredients",
|
||||
"Could not detect instructions",
|
||||
)
|
||||
|
||||
#: English units/labels that must not survive into a finished Swedish recipe.
|
||||
ENGLISH_LEFTOVERS = (
|
||||
"cup", "cups", "tbsp", "tsp", "tablespoon", "teaspoon",
|
||||
"oz", "ounce", "lb", "pound", "clove", "cloves",
|
||||
"servings", "ingredients", "instructions",
|
||||
)
|
||||
|
||||
_DECIMAL_COMMA = re.compile(r"(?<=\d),(?=\d)")
|
||||
_WHITESPACE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def normalize_for_parser(line: str) -> str:
|
||||
"""Return a variant of a Swedish ingredient line the Mealie parser handles better.
|
||||
|
||||
Only converts decimal comma to decimal point (``4,7 dl`` -> ``4.7 dl``).
|
||||
The result is for the parser only; the original line stays canonical for
|
||||
``display``/``note``, because the parser mangles Swedish text.
|
||||
"""
|
||||
return _WHITESPACE.sub(" ", _DECIMAL_COMMA.sub(".", line)).strip()
|
||||
|
||||
|
||||
def clean_title(title: str) -> str:
|
||||
"""Strip campaign/navigation junk that import scrapers append to recipe titles."""
|
||||
cleaned = title.strip()
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for suffix in TITLE_JUNK_SUFFIXES:
|
||||
if cleaned.lower().endswith(suffix.lower()):
|
||||
cleaned = cleaned[: -len(suffix)].strip(" -|–")
|
||||
changed = True
|
||||
return _WHITESPACE.sub(" ", cleaned).strip()
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
"""Build a Mealie-style slug from a Swedish title, preserving å/ä/ö transliteration."""
|
||||
text = title.lower()
|
||||
for source, target in (("å", "a"), ("ä", "a"), ("ö", "o"), ("é", "e"), ("&", "och")):
|
||||
text = text.replace(source, target)
|
||||
text = re.sub(r"[^a-z0-9]+", "-", text)
|
||||
return text.strip("-")
|
||||
|
||||
|
||||
def find_english_leftovers(text: str) -> list[str]:
|
||||
"""Return English unit/label words still present in supposedly Swedish content."""
|
||||
lowered = text.lower()
|
||||
return [word for word in ENGLISH_LEFTOVERS if re.search(rf"\b{re.escape(word)}\b", lowered)]
|
||||
|
||||
|
||||
def has_extraction_failure(recipe: dict) -> bool:
|
||||
"""Detect Mealie's 'could not detect' placeholders anywhere in a recipe."""
|
||||
haystack = [recipe.get("description") or ""]
|
||||
haystack += [i.get("display") or i.get("note") or "" for i in recipe.get("recipeIngredient") or []]
|
||||
haystack += [s.get("text") or "" for s in recipe.get("recipeInstructions") or []]
|
||||
blob = " ".join(haystack)
|
||||
return any(marker in blob for marker in EXTRACTION_FAILURE_MARKERS)
|
||||
|
||||
|
||||
def ingredient_display_lines(recipe: dict) -> list[str]:
|
||||
"""Extract the human-facing ingredient lines, preferring display over note."""
|
||||
lines = []
|
||||
for item in recipe.get("recipeIngredient") or []:
|
||||
text = item.get("display") or item.get("note") or ""
|
||||
if not text:
|
||||
food = item.get("food") or {}
|
||||
text = food.get("name") or ""
|
||||
lines.append(text)
|
||||
return lines
|
||||
|
||||
|
||||
def looks_mangled(line: str) -> bool:
|
||||
"""Heuristic for parser-damaged display text.
|
||||
|
||||
Verified failure modes: vulgar/composed fractions (``47⁄10 liter``), duplicated
|
||||
units, and stray fraction glyphs the Mealie parser emits for Swedish rows.
|
||||
"""
|
||||
if any(glyph in line for glyph in ("⁄", "½", "¼", "¾", "⅓", "⅔")):
|
||||
return True
|
||||
if re.search(r"\b(\w+)\s+\1\b", line, flags=re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,65 @@
|
||||
# External Mealie hostname via Cloudflare
|
||||
|
||||
## Symptom
|
||||
Authenticated API calls to `https://recept.famfallman.com` can fail with:
|
||||
|
||||
- HTTP `403 Forbidden`
|
||||
- response body: `error code: 1010`
|
||||
- `Server: cloudflare` header
|
||||
|
||||
This is not the same as a bad Mealie token (`401`).
|
||||
|
||||
## Reproduction
|
||||
Python `urllib` with its default user-agent may trigger the block:
|
||||
|
||||
```python
|
||||
import os, urllib.request
|
||||
base = 'https://recept.famfallman.com'
|
||||
token = os.environ['MEALIE_API_TOKEN']
|
||||
req = urllib.request.Request(
|
||||
f'{base}/api/users/self',
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=30)
|
||||
```
|
||||
|
||||
Observed failure pattern:
|
||||
- default `Python-urllib/...` user-agent -> `403`
|
||||
- explicit `User-Agent: Hermes-Debug/1.0` -> `200`
|
||||
- `curl` with explicit user-agent -> `200`
|
||||
|
||||
## Working patterns
|
||||
|
||||
### curl
|
||||
```bash
|
||||
BASE_URL="${MEALIE_BASE_URL:-https://recept.famfallman.com}"
|
||||
AUTH=(-H "Authorization: Bearer $MEALIE_API_TOKEN")
|
||||
UA=(-H "User-Agent: Hermes-Debug/1.0")
|
||||
curl -s "$BASE_URL/api/users/self" "${AUTH[@]}" "${UA[@]}"
|
||||
```
|
||||
|
||||
### Python
|
||||
```python
|
||||
headers={
|
||||
'Authorization': f'Bearer {token}',
|
||||
'User-Agent': 'Hermes-Debug/1.0',
|
||||
}
|
||||
```
|
||||
|
||||
## Interpretation
|
||||
If the same token works on the LAN URL and the external host returns Cloudflare `403/1010`, suspect edge/WAF bot filtering before suspecting Mealie auth, DNS, or the token.
|
||||
|
||||
## Import-response quirk discovered in the same session
|
||||
`POST /api/recipes/create/url` may return a JSON string slug like:
|
||||
|
||||
```json
|
||||
"grillat-laxpaket-med-sparris"
|
||||
```
|
||||
|
||||
Do not assume the import response is a full object. Follow with:
|
||||
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/recipes/<slug>" "${AUTH[@]}" "${UA[@]}"
|
||||
```
|
||||
|
||||
to verify title, image, and parsed ingredients.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Fredrik + familjens smakprofil för Mealie-import
|
||||
|
||||
Den här profilen är tänkt att användas när nya recept ska väljas, prioriteras, importeras eller filtreras för Fredrik och familjen.
|
||||
|
||||
## Baslinje från nuvarande Mealie-bibliotek
|
||||
Analysen bygger på cirka 198 recept i Mealie.
|
||||
|
||||
Tydliga mönster i befintliga recept:
|
||||
- mycket **kyckling**, **lax**, **pasta**, **ris**, **grytor** och **ugnsrätter**
|
||||
- stark dragning åt **medelhav/italienskt** och **asiatiskt**
|
||||
- återkommande smaker: **citron**, **vitlök**, **parmesan**, **feta**, **örter**, **chili**, **kokos**, **curry**
|
||||
- tydlig acceptans för både **vardagsmat** och **helgigare rätter**
|
||||
- familjen verkar gilla rätter som är **krämiga**, **smakrikt kryddade**, **syrliga/friska** och ofta har tydlig sälta/umami
|
||||
|
||||
Exempel på representativa recept i biblioteket:
|
||||
- Grönkålssoppa med grön curry
|
||||
- Svamp- och salsicciapasta med tryffel
|
||||
- Kycklingcurry med ris
|
||||
- Citron- och örtris med halloumi
|
||||
- Grillad lax med krämig citron- och spenatsås
|
||||
- Asiatiska köttbullar i krämig kokos- och panengcurrysås
|
||||
- Persisk biff/ärtgryta – Khoresh Gheymeh
|
||||
- Djoje Kebab – saffransdoftande persiska kycklingspett
|
||||
- Fesenjan - Kycklinggryta med valnötter och granatäpple
|
||||
|
||||
## Smaker att prioritera
|
||||
Prioritera recept med en eller flera av dessa egenskaper:
|
||||
|
||||
### Proteiner
|
||||
- kyckling
|
||||
- lax och annan mild fisk
|
||||
- lamm i rätt sammanhang
|
||||
- nöt i gryta, kebab, spett eller färsrätter
|
||||
- korv i smakrika vardagsrätter
|
||||
- vegetariskt när det är tydligt smakdrivet, t.ex. halloumi, feta, örter, citron, svamp eller baljväxter
|
||||
|
||||
### Smakprofil
|
||||
- syrligt och fräscht: citron, lime, granatäpple, yoghurt, örter
|
||||
- varmt och kryddigt: curry, chili, saffran, spiskummin, paprika, vitlök
|
||||
- runt och krämigt: kokosmjölk, yoghurt, ost, gräddiga eller sammetslena såser
|
||||
- umami och sälta: parmesan, feta, brynt/rostad smak, tomat, lök
|
||||
|
||||
### Rätttyper
|
||||
- grytor
|
||||
- soppor
|
||||
- ugnsrätter och gratänger
|
||||
- risrätter
|
||||
- pastarätter
|
||||
- grillat/spett/kebab
|
||||
- familjevänliga plock- eller brickrätter
|
||||
|
||||
## Persisk/iransk prioritering
|
||||
Eftersom Carin/familjen vill ha in mer iranskt ska iranska recept ges **extra hög prioritet** även om de är underrepresenterade i nuvarande bibliotek.
|
||||
|
||||
### Särskilt önskvärda iranska rätter
|
||||
- khoresh-rätter, t.ex. ghormeh sabzi, gheymeh, fesenjan
|
||||
- kebab, koobideh, joojeh/djoojeh, barg
|
||||
- risrätter/polo, särskilt med saffran, dill, bär eller tahdig
|
||||
- ash och andra persiska soppor
|
||||
- rätter med granatäpple, valnötter, saffran, torkad lime, sumak, mynta eller mycket örter
|
||||
- vardagsvänliga iranska kyckling-, ris- och grytrecept
|
||||
|
||||
### Iranska recept ska gärna vara
|
||||
- autentiska eller tydligt iranskinspirerade, inte bara "mellanöstern" i största allmänhet
|
||||
- familjevänliga och möjliga att laga hemma utan alltför specialiserad restaurangutrustning
|
||||
- smakrika men inte beroende av extrem hetta
|
||||
- skrivna/importerade på svenska i Mealie
|
||||
|
||||
## Negativa signaler / lägre prioritet
|
||||
Prioritera ned recept som är:
|
||||
- torra och lågintensiva utan syra, örtighet eller krydddjup
|
||||
- väldigt söta utan balans
|
||||
- ultraprocessade/snackiga snarare än riktiga middagsrecept
|
||||
- alltför amerikanska dessert-/fastfoodkopior om de inte är ovanligt bra
|
||||
- starkt fokuserade på beige buffémat utan friskhet, örter, syra eller kryddkaraktär
|
||||
|
||||
## Praktisk rankingregel för importbevakning
|
||||
När flera kandidater finns, prioritera i ungefär denna ordning:
|
||||
1. iranskt/persiskt som verkar gott och trovärdigt
|
||||
2. recept med kyckling, lax, gryta, ris, citron, örter, yoghurt, saffran, vitlök eller curry
|
||||
3. familjevänliga vardagsrätter med tydlig smakprofil
|
||||
4. vegetariska recept om de fortfarande känns smakrika och "riktiga" som middag
|
||||
5. övrigt
|
||||
|
||||
## Instruktion för framtida importjobb/cron
|
||||
När ett automatiskt jobb ska välja recept att importera:
|
||||
- välj hellre **färre men mer träffsäkra** recept än många svaga
|
||||
- favorisera svenska eller lättöversatta recept med tydliga ingredienser och steg
|
||||
- om två recept verkar lika bra: välj det som är mer **iranskt**, mer **familjevänligt**, eller mer i linje med **kyckling/lax/gryta/ris/citron/örter**
|
||||
- undvik dubletter mot befintliga Mealie-recept
|
||||
- om iranska recept hittas: var mer generös med import även om exakt smakmatch är något osäkrare, eftersom biblioteket aktivt ska breddas åt det hållet
|
||||
@@ -0,0 +1,44 @@
|
||||
# Allrecipes post-import pitfalls
|
||||
|
||||
Den här referensen dokumenterar konkreta fel som observerats i live-körning mot Fredriks Mealie-instans och ska användas när recept importeras från Allrecipes eller andra engelskspråkiga receptsidor.
|
||||
|
||||
## Verifierade problem
|
||||
|
||||
### 1. `image` i API betyder inte nödvändigtvis att omslagsbilden syns i UI
|
||||
- Ett recept kan ha ett icke-tomt `image`-fält och ändå sakna synlig omslagsbild i gränssnittet.
|
||||
- Därför måste importflödet alltid köra ett explicit bildsteg efter import.
|
||||
- För lokal upload på Fredriks Mealie-instans måste `PUT /api/recipes/<slug>/image` skicka med multipart-fältet `extension` (t.ex. `jpg` eller `webp`). Utan det kan uppladdningen fastna i validering och bilden blir inte korrekt sparad.
|
||||
|
||||
### 2. Bulk-actions för taggar/kategorier är opålitliga i denna instans
|
||||
Följande endpoints gav 500-fel under verkliga körningar:
|
||||
- `/api/recipes/bulk-actions/tag`
|
||||
- `/api/recipes/bulk-actions/categorize`
|
||||
|
||||
Använd i stället direkt PATCH på receptets `tags` och `recipeCategory`.
|
||||
|
||||
### 3. Mealie-parsern kan förstöra svenska display-rader
|
||||
Exempel på observerade fel:
|
||||
- `4,7 dl basmatiris` blev något i stil med `47⁄10 liter ...`
|
||||
- parsern gav konstiga bråktecken, dubblerade enheter och skräptecken i `display`
|
||||
- vissa parser-resultat gav märkliga `food.name`-värden för svenska rader
|
||||
|
||||
### 4. Svensk decimal-komma är känsligt
|
||||
Parsern hanterar ofta struktur bättre om decimal-komma först normaliseras till punkt för parseranropet, men den normaliserade raden ska inte användas som slutlig `display` i Mealie.
|
||||
|
||||
## Rekommenderad motstrategi
|
||||
|
||||
1. Bevara alltid en canonical svensk ingrediensrad separat.
|
||||
2. Använd parsern endast för `quantity`, `unit`, `food`.
|
||||
3. Återställ den mänskliga svenska raden till `display` och `note` efter parse.
|
||||
4. Om parsern ger `food.name` utan `id` och du behöver strukturen kvar efter PATCH, skapa/återanvänd riktig Mealie food först.
|
||||
5. För Allrecipes: om scrape-bild inte blir stabil i UI, ladda ner hero-bilden och använd `PUT /api/recipes/<slug>/image`.
|
||||
6. Om Köket.se- och YouTube-importer fortsätter att visa fungerande omslagsbilder medan Allrecipes-importer inte gör det, behandla det som ett källspecifikt Allrecipes-problem. Gå då direkt till explicit hero-bild-download + `PUT /api/recipes/<slug>/image` i stället för bredare generell felsökning.
|
||||
7. Om parsern fortfarande gör visningen ful efter cleanup, välj läsbarhet före struktur för just de trasiga raderna: nollställ `quantity` / `unit` / `food` och spara rena svenska `display` / `note` i stället för att behålla dålig automatisk rendering.
|
||||
|
||||
## Definition av klar import
|
||||
Ett importerat recept räknas inte som klart förrän:
|
||||
- svensk titel/beskrivning/instruktioner är satta
|
||||
- `recipeIngredient[].display` ser normala ut för människa
|
||||
- parsern inte lämnar receptet i `Click Parse`-läge
|
||||
- bild har satts eller verifierats explicit
|
||||
- taggar/kategorier är applicerade eller medvetet hoppade över
|
||||
@@ -0,0 +1,31 @@
|
||||
# Köket-attribution efter Mealie-import
|
||||
|
||||
När ett recept importeras från `koket.se` följer inte receptmakare/program alltid med på ett synligt sätt i Mealie.
|
||||
|
||||
Verifierat arbetssätt:
|
||||
|
||||
1. Importera receptet via `POST /api/recipes/create/url`.
|
||||
2. Läs källsidan och hämta explicit attribution från sidan själv:
|
||||
- `Av: <namn>` → använd som `extras.author`
|
||||
- `Från: <program>` → använd som `extras.source`
|
||||
3. Läs tillbaka receptet via slug.
|
||||
4. PATCH:a receptet så att attribution sparas både som metadata och synligt:
|
||||
|
||||
```json
|
||||
{
|
||||
"extras": {
|
||||
"author": "Zeina Mourtada",
|
||||
"source": "Kökets middag"
|
||||
},
|
||||
"description": "... Recept av Zeina Mourtada. Från Kökets middag."
|
||||
}
|
||||
```
|
||||
|
||||
Varför båda behövs:
|
||||
- `extras.author` / `extras.source` är API-korrekt lagring.
|
||||
- En kort text i `description` gör attributionen synlig i vanliga Mealie-vyer.
|
||||
|
||||
Regel:
|
||||
- Gissa aldrig författare eller källa.
|
||||
- Ta bara värden som uttryckligen står på källsidan.
|
||||
- Verifiera efter PATCH att både `extras` och `description` faktiskt uppdaterades.
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Small, confirmation-friendly MCP facade over the Mealie API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
from .normalize import (
|
||||
clean_title,
|
||||
has_extraction_failure,
|
||||
ingredient_display_lines,
|
||||
looks_mangled,
|
||||
normalize_for_parser,
|
||||
)
|
||||
from .verify import verify_recipe as run_verification
|
||||
|
||||
BASE_URL = os.getenv("MEALIE_BASE_URL", "https://recept.famfallman.com").rstrip("/")
|
||||
TOKEN = os.getenv("MEALIE_API_TOKEN")
|
||||
UA = os.getenv("MEALIE_USER_AGENT", "Mealie-MCP/0.1")
|
||||
HA_BASE_URL = (os.getenv("HA_BASE_URL") or "").rstrip("/")
|
||||
HA_TOKEN = os.getenv("HA_TOKEN")
|
||||
SHOPPING_LIST_ENTITY = os.getenv("HA_SHOPPING_LIST_ENTITY", "todo.ourgroceries_shoppinglista")
|
||||
|
||||
RESOURCE_DIR = Path(__file__).parent / "resources"
|
||||
|
||||
mcp = MCPServer("mealie", version="0.1.0")
|
||||
|
||||
|
||||
class MealieAuthError(RuntimeError):
|
||||
"""The Mealie token is missing, expired, or wrong (HTTP 401)."""
|
||||
|
||||
|
||||
class MealieBlockedError(RuntimeError):
|
||||
"""The edge/WAF rejected the client fingerprint (HTTP 403, Cloudflare 1010)."""
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
if not TOKEN:
|
||||
raise RuntimeError("MEALIE_API_TOKEN is not set")
|
||||
return httpx.Client(
|
||||
base_url=BASE_URL,
|
||||
headers={"Authorization": f"Bearer {TOKEN}", "User-Agent": UA},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def _raise_for_status(response: httpx.Response) -> None:
|
||||
"""Map Mealie/Cloudflare failures to distinguishable errors.
|
||||
|
||||
A 403 with Cloudflare's ``error code: 1010`` is a blocked client fingerprint,
|
||||
not a bad token — conflating the two sends debugging in the wrong direction.
|
||||
"""
|
||||
if response.status_code == 401:
|
||||
raise MealieAuthError(
|
||||
"401 from Mealie: the API token is missing, expired, or wrong. "
|
||||
"Create a new one at /user/profile/api-tokens."
|
||||
)
|
||||
if response.status_code == 403 and "1010" in response.text:
|
||||
raise MealieBlockedError(
|
||||
f"403 from the edge (Cloudflare error 1010) for User-Agent {UA!r}. "
|
||||
"This is a WAF rule on the client fingerprint, not a Mealie auth problem. "
|
||||
"Set MEALIE_USER_AGENT to a non-default value."
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs: Any) -> Any:
|
||||
with _client() as client:
|
||||
response = client.request(method, path, **kwargs)
|
||||
_raise_for_status(response)
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
|
||||
# --- Read -------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def check_auth() -> dict[str, Any]:
|
||||
"""Verify the Mealie token and distinguish auth failures from WAF blocks."""
|
||||
try:
|
||||
user = _request("GET", "/api/users/self")
|
||||
except MealieAuthError as exc:
|
||||
return {"ok": False, "reason": "auth", "detail": str(exc)}
|
||||
except MealieBlockedError as exc:
|
||||
return {"ok": False, "reason": "waf", "detail": str(exc)}
|
||||
return {
|
||||
"ok": True,
|
||||
"base_url": BASE_URL,
|
||||
"user": user.get("username"),
|
||||
"user_agent": UA,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def search_recipes(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
"""Search Mealie recipes by text."""
|
||||
data = _request("GET", "/api/recipes", params={"search": query, "perPage": limit})
|
||||
return data.get("items", data if isinstance(data, list) else [])
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def suggest_recipes(foods: list[str], limit: int = 10, max_missing_foods: int = 0) -> Any:
|
||||
"""Find recipes matching ingredients on hand."""
|
||||
return _request("GET", "/api/recipes/suggestions", params={
|
||||
"foods": ",".join(foods), "limit": limit,
|
||||
"maxMissingFoods": max_missing_foods, "includeFoodsOnHand": "true",
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_recipe(slug_or_id: str) -> dict[str, Any]:
|
||||
"""Read complete recipe details."""
|
||||
return _request("GET", f"/api/recipes/{slug_or_id}")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_organizers() -> dict[str, list[str]]:
|
||||
"""List existing categories and tags so imports reuse the library's vocabulary.
|
||||
|
||||
Read this before tagging anything: the library has settled naming (`Källa: Köket`,
|
||||
`📅 Vardag`, `💨 Airfryer`) and inventing parallel names fragments retrieval.
|
||||
"""
|
||||
def names(path: str) -> list[str]:
|
||||
data = _request("GET", path, params={"perPage": 200})
|
||||
items = data.get("items", data if isinstance(data, list) else [])
|
||||
return sorted(item.get("name", "") for item in items)
|
||||
|
||||
return {
|
||||
"categories": names("/api/organizers/categories"),
|
||||
"tags": names("/api/organizers/tags"),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def find_by_source_url(url: str) -> list[dict[str, Any]]:
|
||||
"""Look for an existing recipe with this source URL, to avoid duplicate imports.
|
||||
|
||||
``orgURL`` is the reliable duplicate key; imported titles and slugs legitimately
|
||||
differ from the URL text, so matching on those produces false negatives.
|
||||
"""
|
||||
data = _request("GET", "/api/recipes", params={"perPage": 500})
|
||||
items = data.get("items", data if isinstance(data, list) else [])
|
||||
return [item for item in items if (item.get("orgURL") or "").rstrip("/") == url.rstrip("/")]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def scale_ingredients(slug_or_id: str, servings: int) -> dict[str, Any]:
|
||||
"""Return readable ingredient lines scaled to a requested serving count."""
|
||||
recipe = get_recipe(slug_or_id)
|
||||
original = recipe.get("recipeYield") or recipe.get("recipeServings")
|
||||
if not original:
|
||||
raise ValueError("Recipe has no serving/yield metadata; scaling is not exact")
|
||||
try:
|
||||
base = Decimal(str(original).split()[0])
|
||||
factor = Decimal(servings) / base
|
||||
except Exception as exc:
|
||||
raise ValueError("Recipe serving metadata is not numeric") from exc
|
||||
lines = []
|
||||
for item in recipe.get("recipeIngredient", []):
|
||||
text = item.get("display") or item.get("note") or item.get("food", {}).get("name", "")
|
||||
quantity = item.get("quantity")
|
||||
if quantity is not None:
|
||||
scaled = (Decimal(str(quantity)) * factor).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
|
||||
text = text.replace(str(quantity), format(scaled, "f"), 1)
|
||||
lines.append(text)
|
||||
return {"recipe": recipe.get("name"), "servings": servings, "ingredients": lines}
|
||||
|
||||
|
||||
# --- Import -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _import_report(recipe: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Flag extraction problems the caller must fix before claiming success."""
|
||||
title = recipe.get("name") or ""
|
||||
cleaned = clean_title(title)
|
||||
return {
|
||||
"slug": recipe.get("slug"),
|
||||
"title": title,
|
||||
"suggested_title": cleaned if cleaned != title else None,
|
||||
"extraction_failed": has_extraction_failure(recipe),
|
||||
"has_image_field": bool(recipe.get("image")),
|
||||
"ingredient_count": len(recipe.get("recipeIngredient") or []),
|
||||
"instruction_count": len(recipe.get("recipeInstructions") or []),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def import_recipe_url(url: str, check_duplicates: bool = True) -> dict[str, Any]:
|
||||
"""Import a recipe page through Mealie's URL scraper.
|
||||
|
||||
Returns the created recipe plus an extraction report. A non-empty response is
|
||||
not success: check ``extraction_failed`` and ``suggested_title`` before
|
||||
reporting, and run the Swedish normalization pass afterwards.
|
||||
"""
|
||||
if check_duplicates:
|
||||
existing = find_by_source_url(url)
|
||||
if existing:
|
||||
return {
|
||||
"imported": False,
|
||||
"reason": "duplicate",
|
||||
"existing": [{"slug": r.get("slug"), "name": r.get("name")} for r in existing],
|
||||
}
|
||||
created = _request("POST", "/api/recipes/create/url", json={"url": url})
|
||||
recipe = get_recipe(created) if isinstance(created, str) else created
|
||||
return {"imported": True, "recipe": recipe, "report": _import_report(recipe)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def import_recipe_text(text: str, source_url: str | None = None) -> dict[str, Any]:
|
||||
"""Import a normalized recipe text block (OCR, transcript, or hand-built Swedish).
|
||||
|
||||
Use this when the source is not a clean single-recipe page — an editorial
|
||||
roundup, a YouTube description, or a social caption. Build the text in Swedish
|
||||
with ``Titel`` / ``Portioner`` / ``Ingredienser`` / ``Gör så här`` sections.
|
||||
"""
|
||||
payload: dict[str, Any] = {"data": text}
|
||||
if source_url:
|
||||
payload["url"] = source_url
|
||||
created = _request("POST", "/api/recipes/create/html-or-json", json=payload)
|
||||
recipe = get_recipe(created) if isinstance(created, str) else created
|
||||
return {"imported": True, "recipe": recipe, "report": _import_report(recipe)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def import_recipe_image(image_path: str) -> dict[str, Any]:
|
||||
"""Import a recipe from a photo or screenshot via Mealie's image parser."""
|
||||
path = Path(image_path)
|
||||
if not path.is_file():
|
||||
raise ValueError(f"No such image file: {image_path}")
|
||||
with _client() as client, path.open("rb") as handle:
|
||||
response = client.post("/api/recipes/create/image", files={"images": (path.name, handle)})
|
||||
_raise_for_status(response)
|
||||
created = response.json()
|
||||
recipe = get_recipe(created) if isinstance(created, str) else created
|
||||
return {"imported": True, "recipe": recipe, "report": _import_report(recipe)}
|
||||
|
||||
|
||||
# --- Write ------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Apply an explicit Mealie recipe patch, then read the recipe back.
|
||||
|
||||
Use this for tags and categories too — the bulk-action endpoints return 500 on
|
||||
this instance. Patch ``recipeCategory`` and ``tags`` directly.
|
||||
"""
|
||||
_request("PATCH", f"/api/recipes/{slug_or_id}", json=patch)
|
||||
return get_recipe(slug_or_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def parse_ingredients(slug_or_id: str) -> dict[str, Any]:
|
||||
"""Add structured food/unit/quantity data while keeping the Swedish display text.
|
||||
|
||||
The parser is a structured-data helper only. Its ``display`` output mangles
|
||||
Swedish rows (``4,7 dl basmatiris`` came back as ``47⁄10 liter ...``), so the
|
||||
canonical human line is preserved and restored after parsing. Rows the parser
|
||||
still mangles keep readable text with structure cleared — legibility wins.
|
||||
"""
|
||||
recipe = get_recipe(slug_or_id)
|
||||
canonical = ingredient_display_lines(recipe)
|
||||
if not canonical:
|
||||
return {"parsed": False, "reason": "Recipe has no ingredient lines"}
|
||||
|
||||
parsed = _request(
|
||||
"POST",
|
||||
"/api/parser/ingredients",
|
||||
json={"ingredients": [normalize_for_parser(line) for line in canonical], "parser": "nlp"},
|
||||
)
|
||||
|
||||
ingredients = list(recipe.get("recipeIngredient") or [])
|
||||
degraded: list[str] = []
|
||||
for index, result in enumerate(parsed or []):
|
||||
if index >= len(ingredients):
|
||||
break
|
||||
item = dict(ingredients[index])
|
||||
confidence = (result or {}).get("ingredient") or {}
|
||||
item["quantity"] = confidence.get("quantity")
|
||||
item["unit"] = confidence.get("unit")
|
||||
item["food"] = confidence.get("food")
|
||||
# The canonical Swedish line always wins over parser-generated display text.
|
||||
item["display"] = canonical[index]
|
||||
item["note"] = canonical[index]
|
||||
if looks_mangled(canonical[index]):
|
||||
item["quantity"] = None
|
||||
item["unit"] = None
|
||||
item["food"] = None
|
||||
degraded.append(canonical[index])
|
||||
ingredients[index] = item
|
||||
|
||||
try:
|
||||
_request("PATCH", f"/api/recipes/{slug_or_id}", json={"recipeIngredient": ingredients})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
# A full recipeIngredient patch with nested parser objects can 500 here.
|
||||
# Preserve the readable import rather than corrupting the recipe.
|
||||
return {
|
||||
"parsed": False,
|
||||
"reason": f"Structured patch rejected by Mealie ({exc.response.status_code}); "
|
||||
"readable Swedish lines were left intact",
|
||||
"canonical_lines": canonical,
|
||||
}
|
||||
|
||||
return {
|
||||
"parsed": True,
|
||||
"recipe": get_recipe(slug_or_id),
|
||||
"readability_fallback_rows": degraded,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_cover_image(
|
||||
slug_or_id: str,
|
||||
source_url: str | None = None,
|
||||
image_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Set a recipe cover image by scraping a URL or uploading a local file.
|
||||
|
||||
Local uploads must send the multipart ``extension`` field or the upload fails
|
||||
validation silently on this instance. When a scrape does not produce a visible
|
||||
cover (observed on Allrecipes), download the hero image and upload it instead.
|
||||
"""
|
||||
if not source_url and not image_path:
|
||||
raise ValueError("Provide either source_url or image_path")
|
||||
|
||||
if image_path:
|
||||
path = Path(image_path)
|
||||
if not path.is_file():
|
||||
raise ValueError(f"No such image file: {image_path}")
|
||||
extension = path.suffix.lstrip(".").lower() or "jpg"
|
||||
with _client() as client, path.open("rb") as handle:
|
||||
response = client.put(
|
||||
f"/api/recipes/{slug_or_id}/image",
|
||||
files={"image": (path.name, handle)},
|
||||
data={"extension": extension},
|
||||
)
|
||||
_raise_for_status(response)
|
||||
method = f"upload ({extension})"
|
||||
else:
|
||||
_request("POST", f"/api/recipes/{slug_or_id}/image", json={"url": source_url})
|
||||
method = "scrape"
|
||||
|
||||
recipe = get_recipe(slug_or_id)
|
||||
return {"method": method, "image": recipe.get("image"), "slug": recipe.get("slug")}
|
||||
|
||||
|
||||
# --- Verify -----------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def verify_recipe(
|
||||
slug_or_id: str,
|
||||
image_verified: bool = False,
|
||||
taxonomy_skipped_reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run the finished-import checklist and return pass/fail per check.
|
||||
|
||||
Set ``image_verified`` only if you actually ran an image step — a non-empty
|
||||
``image`` field does not prove the UI shows a cover.
|
||||
"""
|
||||
recipe = get_recipe(slug_or_id)
|
||||
return run_verification(
|
||||
recipe,
|
||||
image_verified=image_verified,
|
||||
taxonomy_skipped_reason=taxonomy_skipped_reason,
|
||||
)
|
||||
|
||||
|
||||
# --- Home Assistant ---------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def shopping_list_add(items: list[str]) -> dict[str, Any]:
|
||||
"""Add ingredient lines to the Home Assistant shopping list.
|
||||
|
||||
Each line must carry its quantity — the todo entity has no structured quantity
|
||||
field, so `400 g kycklinglårfilé`, not `kycklinglårfilé`. Duplicates are never
|
||||
silently removed; consolidation is a separate, explicit operation.
|
||||
"""
|
||||
if not HA_BASE_URL or not HA_TOKEN:
|
||||
raise RuntimeError("HA_BASE_URL and HA_TOKEN must be set to write the shopping list")
|
||||
added: list[str] = []
|
||||
with httpx.Client(
|
||||
base_url=HA_BASE_URL,
|
||||
headers={"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"},
|
||||
timeout=30,
|
||||
) as client:
|
||||
for item in items:
|
||||
response = client.post(
|
||||
"/api/services/todo/add_item",
|
||||
json={"entity_id": SHOPPING_LIST_ENTITY, "item": item},
|
||||
)
|
||||
response.raise_for_status()
|
||||
added.append(item)
|
||||
return {"entity_id": SHOPPING_LIST_ENTITY, "added": added, "count": len(added)}
|
||||
|
||||
|
||||
# --- Resources and prompts --------------------------------------------------
|
||||
|
||||
|
||||
def _resource(filename: str) -> str:
|
||||
return (RESOURCE_DIR / filename).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@mcp.resource("mealie://reference/family-taste-profile")
|
||||
def family_taste_profile() -> str:
|
||||
"""Fredrik's household taste profile, for judging whether a recipe is a good fit."""
|
||||
return _resource("family-taste-profile.md")
|
||||
|
||||
|
||||
@mcp.resource("mealie://reference/import-pitfalls")
|
||||
def import_pitfalls() -> str:
|
||||
"""Verified post-import failure modes: image, parser, bulk-action, and decimal issues."""
|
||||
return _resource("import-pitfalls.md")
|
||||
|
||||
|
||||
@mcp.resource("mealie://reference/koket-attribution")
|
||||
def koket_attribution() -> str:
|
||||
"""How to preserve Köket author/programme attribution after import."""
|
||||
return _resource("koket-attribution.md")
|
||||
|
||||
|
||||
@mcp.resource("mealie://reference/cloudflare-hostname")
|
||||
def cloudflare_hostname() -> str:
|
||||
"""Why the external hostname returns 403/1010 and how to avoid it."""
|
||||
return _resource("cloudflare-hostname.md")
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def import_pipeline(source: str) -> str:
|
||||
"""The full import order, so no step gets skipped."""
|
||||
return f"""Import this source into Mealie: {source}
|
||||
|
||||
Follow this order exactly. Skipping a step is how imports end up half-finished.
|
||||
|
||||
1. `check_auth` if you have not already this session.
|
||||
2. `find_by_source_url` — stop and report if it already exists.
|
||||
3. Import with `import_recipe_url` (single recipe page) or `import_recipe_text`
|
||||
(editorial roundup, transcript, caption). Read the returned report: if
|
||||
`extraction_failed` is true, rebuild the recipe manually instead of patching.
|
||||
4. `list_organizers` to see the existing category/tag vocabulary.
|
||||
5. `patch_recipe` with Swedish title, description, instructions, and ingredient
|
||||
lines, plus `orgURL`, `extras.author`, `extras.source`, `tags`, and
|
||||
`recipeCategory`. Apply `suggested_title` if the report offered one.
|
||||
6. `set_cover_image` explicitly — do not trust a non-empty `image` field.
|
||||
7. `parse_ingredients` to add structure while keeping the Swedish display text.
|
||||
8. `verify_recipe` with `image_verified=True`. Do not report success until
|
||||
`complete` is true, or state exactly which checks failed and why.
|
||||
|
||||
All stored values must be Swedish: title, description, ingredient names,
|
||||
instructions, and units (`msk`, `tsk`, `dl`, `krm`, `st`, `klyfta`).
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
mcp.run()
|
||||
@@ -0,0 +1,199 @@
|
||||
"""The 'definition of a finished import' as executable checks.
|
||||
|
||||
This exists so an agent cannot report a successful import while the recipe still
|
||||
has an English title, a missing cover image, or unparsed ingredients. Pure
|
||||
functions over a recipe dict — see resources/import-pitfalls.md for the field
|
||||
observations these encode.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from .normalize import (
|
||||
find_english_leftovers,
|
||||
has_extraction_failure,
|
||||
ingredient_display_lines,
|
||||
looks_mangled,
|
||||
)
|
||||
|
||||
Status = Literal["pass", "fail", "warn"]
|
||||
|
||||
|
||||
def _check(check_id: str, status: Status, detail: str) -> dict[str, str]:
|
||||
return {"check": check_id, "status": status, "detail": detail}
|
||||
|
||||
|
||||
def is_parsed(recipe: dict[str, Any]) -> bool:
|
||||
"""Whether Mealie will stop showing 'your ingredients aren't parsed yet'.
|
||||
|
||||
Mealie treats ingredients as parsed once they carry structured data; an
|
||||
ingredient with only free text keeps the recipe in the 'Click Parse' state.
|
||||
"""
|
||||
ingredients = recipe.get("recipeIngredient") or []
|
||||
if not ingredients:
|
||||
return False
|
||||
return any(
|
||||
(item.get("food") or {}).get("id") or (item.get("unit") or {}).get("id")
|
||||
for item in ingredients
|
||||
)
|
||||
|
||||
|
||||
def verify_recipe(
|
||||
recipe: dict[str, Any],
|
||||
*,
|
||||
image_verified: bool = False,
|
||||
taxonomy_skipped_reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run every finished-import check and return a pass/fail report.
|
||||
|
||||
``image_verified`` should only be set by a caller that actually ran an image
|
||||
step, because a non-empty ``image`` field alone does not prove the UI shows a
|
||||
cover (verified failure mode on Allrecipes imports).
|
||||
|
||||
``taxonomy_skipped_reason`` records a deliberate decision to leave a recipe
|
||||
uncategorized, which turns that check into a pass instead of a failure.
|
||||
"""
|
||||
checks: list[dict[str, str]] = []
|
||||
|
||||
name = (recipe.get("name") or "").strip()
|
||||
checks.append(
|
||||
_check("title_present", "pass" if name else "fail", name or "Recipe has no title")
|
||||
)
|
||||
|
||||
description = (recipe.get("description") or "").strip()
|
||||
checks.append(
|
||||
_check(
|
||||
"description_present",
|
||||
"pass" if description else "fail",
|
||||
"Description set" if description else "Description is empty",
|
||||
)
|
||||
)
|
||||
|
||||
instructions = [
|
||||
(step.get("text") or "").strip() for step in recipe.get("recipeInstructions") or []
|
||||
]
|
||||
instructions = [step for step in instructions if step]
|
||||
checks.append(
|
||||
_check(
|
||||
"instructions_present",
|
||||
"pass" if instructions else "fail",
|
||||
f"{len(instructions)} step(s)" if instructions else "No instruction steps",
|
||||
)
|
||||
)
|
||||
|
||||
leftovers = find_english_leftovers(" ".join([name, description, *instructions]))
|
||||
checks.append(
|
||||
_check(
|
||||
"swedish_prose",
|
||||
"pass" if not leftovers else "fail",
|
||||
"No English unit/label words found"
|
||||
if not leftovers
|
||||
else f"English leftovers in title/description/instructions: {', '.join(leftovers)}",
|
||||
)
|
||||
)
|
||||
|
||||
lines = ingredient_display_lines(recipe)
|
||||
empty_lines = [index for index, line in enumerate(lines) if not line.strip()]
|
||||
checks.append(
|
||||
_check(
|
||||
"ingredient_lines_present",
|
||||
"pass" if lines and not empty_lines else "fail",
|
||||
f"{len(lines)} ingredient line(s)"
|
||||
if lines and not empty_lines
|
||||
else f"Missing display text at index {empty_lines}"
|
||||
if lines
|
||||
else "Recipe has no ingredients",
|
||||
)
|
||||
)
|
||||
|
||||
ingredient_leftovers = find_english_leftovers(" ".join(lines))
|
||||
checks.append(
|
||||
_check(
|
||||
"swedish_ingredients",
|
||||
"pass" if not ingredient_leftovers else "fail",
|
||||
"Ingredient lines are Swedish"
|
||||
if not ingredient_leftovers
|
||||
else f"English leftovers in ingredient lines: {', '.join(ingredient_leftovers)}",
|
||||
)
|
||||
)
|
||||
|
||||
mangled = [line for line in lines if looks_mangled(line)]
|
||||
checks.append(
|
||||
_check(
|
||||
"ingredient_lines_readable",
|
||||
"pass" if not mangled else "fail",
|
||||
"Display text is clean"
|
||||
if not mangled
|
||||
else f"Parser-mangled display text: {mangled}",
|
||||
)
|
||||
)
|
||||
|
||||
parsed = is_parsed(recipe)
|
||||
checks.append(
|
||||
_check(
|
||||
"ingredients_parsed",
|
||||
"pass" if parsed else "fail",
|
||||
"Structured food/unit data present"
|
||||
if parsed
|
||||
else "Recipe still shows the 'Click Parse' state",
|
||||
)
|
||||
)
|
||||
|
||||
checks.append(
|
||||
_check(
|
||||
"no_extraction_placeholders",
|
||||
"fail" if has_extraction_failure(recipe) else "pass",
|
||||
"Contains 'Could not detect ...' placeholder text"
|
||||
if has_extraction_failure(recipe)
|
||||
else "No scraper placeholders",
|
||||
)
|
||||
)
|
||||
|
||||
has_image_field = bool(recipe.get("image"))
|
||||
if image_verified:
|
||||
image_status: Status = "pass"
|
||||
image_detail = "Image step ran and was verified by the caller"
|
||||
elif has_image_field:
|
||||
image_status = "warn"
|
||||
image_detail = (
|
||||
"image field is set but no image step was verified; a non-empty image "
|
||||
"does not prove the UI shows a cover"
|
||||
)
|
||||
else:
|
||||
image_status = "fail"
|
||||
image_detail = "No cover image"
|
||||
checks.append(_check("cover_image", image_status, image_detail))
|
||||
|
||||
categories = recipe.get("recipeCategory") or []
|
||||
tags = recipe.get("tags") or []
|
||||
if categories or tags:
|
||||
checks.append(
|
||||
_check(
|
||||
"taxonomy",
|
||||
"pass",
|
||||
f"{len(categories)} category/categories, {len(tags)} tag(s)",
|
||||
)
|
||||
)
|
||||
elif taxonomy_skipped_reason:
|
||||
checks.append(_check("taxonomy", "pass", f"Deliberately skipped: {taxonomy_skipped_reason}"))
|
||||
else:
|
||||
checks.append(_check("taxonomy", "fail", "No categories or tags, and no skip reason given"))
|
||||
|
||||
source = recipe.get("orgURL") or (recipe.get("extras") or {}).get("source")
|
||||
checks.append(
|
||||
_check(
|
||||
"attribution",
|
||||
"pass" if source else "warn",
|
||||
f"Source recorded: {source}" if source else "No orgURL or extras.source",
|
||||
)
|
||||
)
|
||||
|
||||
failed = [c for c in checks if c["status"] == "fail"]
|
||||
warned = [c for c in checks if c["status"] == "warn"]
|
||||
return {
|
||||
"recipe": name or recipe.get("slug"),
|
||||
"complete": not failed,
|
||||
"failed": [c["check"] for c in failed],
|
||||
"warnings": [c["check"] for c in warned],
|
||||
"checks": checks,
|
||||
}
|
||||
Reference in New Issue
Block a user