commit f2d233b43061772cddf6607371d026c47835929f Author: Fizz Date: Fri Jul 31 07:31:10 2026 +0200 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d405a58 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Required +MEALIE_API_TOKEN= + +# Optional — defaults shown +MEALIE_BASE_URL=https://recept.famfallman.com +MEALIE_USER_AGENT=Mealie-MCP/0.1 + +# Optional — only needed for shopping_list_add +HA_BASE_URL= +HA_TOKEN= +HA_SHOPPING_LIST_ENTITY=todo.ourgroceries_shoppinglista diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0623cb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ +*.egg-info/ +dist/ +build/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..e600618 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Mealie MCP Server + +MCP server for Fredrik's Mealie instance. It exposes recipe search, suggestions, +recipe details, ingredient scaling, imports, cover images, ingredient parsing, and a +finished-import verification pass. + +It grew out of a working Hermes agent skill. The valuable part of that skill was not +the Mealie endpoints — it was roughly 25 operational rules discovered by running +imports against the live instance and watching them fail. Those rules are the reason +this server exists as code instead of prompt text. + +## What lives where + +The split is deliberate: + +| The server owns (deterministic) | The model owns (judgement) | +|---|---| +| Auth, and the non-default User-Agent Cloudflare requires | Translating content to Swedish | +| The `extension` field on cover uploads | Picking recipes against the taste profile | +| PATCH instead of the bulk-action endpoints | Choosing a variant from an article page | +| Parsing, then restoring the Swedish display text | Sensible categories and tags | +| Decimal-comma normalization before the parser | Judging image quality | + +The token never leaves the server, so agents never handle a bearer credential. + +## Configuration + +```bash +export MEALIE_API_TOKEN='...' # required +export MEALIE_BASE_URL='https://recept.famfallman.com' # optional +export MEALIE_USER_AGENT='Mealie-MCP/0.1' # optional, must be non-default + +export HA_BASE_URL='http://homeassistant.local:8123' # optional, shopping list only +export HA_TOKEN='...' +export HA_SHOPPING_LIST_ENTITY='todo.ourgroceries_shoppinglista' + +uv run mealie-mcp +``` + +Create the Mealie token at `/user/profile/api-tokens`. + +Home Assistant is optional and isolated: without `HA_BASE_URL` and `HA_TOKEN` +everything except `shopping_list_add` works normally. + +## Tools + +**Read** +- `check_auth()` — distinguishes a bad token (401) from a Cloudflare block (403/1010) +- `search_recipes(query, limit)` +- `suggest_recipes(foods, limit, max_missing_foods)` +- `get_recipe(slug_or_id)` +- `list_organizers()` — existing categories and tags, so imports reuse the vocabulary +- `find_by_source_url(url)` — duplicate check on `orgURL` +- `scale_ingredients(slug_or_id, servings)` + +**Import** +- `import_recipe_url(url, check_duplicates=True)` +- `import_recipe_text(text, source_url=None)` +- `import_recipe_image(image_path)` + +Each returns an extraction report flagging `Could not detect ...` placeholders and +campaign junk in the title. A response is not proof of a good import. + +**Write** +- `patch_recipe(slug_or_id, patch)` +- `parse_ingredients(slug_or_id)` +- `set_cover_image(slug_or_id, source_url=None, image_path=None)` +- `shopping_list_add(items)` + +**Verify** +- `verify_recipe(slug_or_id, image_verified=False, taxonomy_skipped_reason=None)` + +Writes are deliberately separate tools so a client can request confirmation. + +## `verify_recipe` + +The one that keeps the rest honest. It runs the finished-import definition as code +and returns pass/fail per check, 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. + +`image_verified` must be passed explicitly by a caller that actually ran an image step. +A non-empty `image` field is only a warning: it does not prove the UI shows a cover. + +## Resources + +The reference notes ship as MCP resources rather than prompt text: + +- `mealie://reference/family-taste-profile` +- `mealie://reference/import-pitfalls` +- `mealie://reference/koket-attribution` +- `mealie://reference/cloudflare-hostname` + +## Prompt + +`import_pipeline(source)` spells out the order — import → normalize → image → parse → +verify — so no step gets skipped. + +## Out of scope + +The Obsidian meal plan. That is file writing in a vault and belongs to the client's own +file tools; folding it in would make this server two things at once. + +## Tests + +```bash +python -m pytest +``` + +The suite covers the verified failure modes: Cloudflare 1010 vs 401, decimal-comma +normalization, parser-mangled display text, the required `extension` field on uploads, +the 500 on structured ingredient patches, and duplicate detection by `orgURL`. diff --git a/mealie_mcp/__init__.py b/mealie_mcp/__init__.py new file mode 100644 index 0000000..7c6a91c --- /dev/null +++ b/mealie_mcp/__init__.py @@ -0,0 +1 @@ +"""Mealie MCP server.""" diff --git a/mealie_mcp/normalize.py b/mealie_mcp/normalize.py new file mode 100644 index 0000000..9bcfe82 --- /dev/null +++ b/mealie_mcp/normalize.py @@ -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 diff --git a/mealie_mcp/resources/cloudflare-hostname.md b/mealie_mcp/resources/cloudflare-hostname.md new file mode 100644 index 0000000..eb66ccb --- /dev/null +++ b/mealie_mcp/resources/cloudflare-hostname.md @@ -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/" "${AUTH[@]}" "${UA[@]}" +``` + +to verify title, image, and parsed ingredients. diff --git a/mealie_mcp/resources/family-taste-profile.md b/mealie_mcp/resources/family-taste-profile.md new file mode 100644 index 0000000..cd38480 --- /dev/null +++ b/mealie_mcp/resources/family-taste-profile.md @@ -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 diff --git a/mealie_mcp/resources/import-pitfalls.md b/mealie_mcp/resources/import-pitfalls.md new file mode 100644 index 0000000..d8f017f --- /dev/null +++ b/mealie_mcp/resources/import-pitfalls.md @@ -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//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//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//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 diff --git a/mealie_mcp/resources/koket-attribution.md b/mealie_mcp/resources/koket-attribution.md new file mode 100644 index 0000000..22fc9b7 --- /dev/null +++ b/mealie_mcp/resources/koket-attribution.md @@ -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: ` → använd som `extras.author` + - `Från: ` → 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. diff --git a/mealie_mcp/server.py b/mealie_mcp/server.py new file mode 100644 index 0000000..3b80519 --- /dev/null +++ b/mealie_mcp/server.py @@ -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() diff --git a/mealie_mcp/verify.py b/mealie_mcp/verify.py new file mode 100644 index 0000000..9313a65 --- /dev/null +++ b/mealie_mcp/verify.py @@ -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, + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..020e653 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "mealie-mcp" +version = "0.1.0" +description = "MCP tools for Mealie recipe planning" +requires-python = ">=3.11" +dependencies = ["mcp>=2.0.0", "httpx>=0.27.0"] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[project.scripts] +mealie-mcp = "mealie_mcp.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mealie_mcp"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_normalize.py b/tests/test_normalize.py new file mode 100644 index 0000000..2c5d076 --- /dev/null +++ b/tests/test_normalize.py @@ -0,0 +1,99 @@ +"""Cases taken from failures verified against the live Mealie instance.""" +from __future__ import annotations + +import pytest + +from mealie_mcp.normalize import ( + clean_title, + find_english_leftovers, + has_extraction_failure, + ingredient_display_lines, + looks_mangled, + normalize_for_parser, + slugify, +) + + +class TestNormalizeForParser: + def test_converts_swedish_decimal_comma(self): + assert normalize_for_parser("4,7 dl basmatiris") == "4.7 dl basmatiris" + + def test_leaves_list_commas_alone(self): + assert normalize_for_parser("salt, peppar") == "salt, peppar" + + def test_collapses_whitespace(self): + assert normalize_for_parser(" 2 msk olja ") == "2 msk olja" + + def test_is_idempotent(self): + once = normalize_for_parser("0,6 dl grädde") + assert normalize_for_parser(once) == once + + +class TestCleanTitle: + @pytest.mark.parametrize( + "raw,expected", + [ + ("Fläskfilé med svampsås - se & gör", "Fläskfilé med svampsås"), + ("Kycklinggryta | Köket.se", "Kycklinggryta"), + ("Pannkakor - se och gör", "Pannkakor"), + ("Lax i ugn", "Lax i ugn"), + ], + ) + def test_strips_campaign_suffixes(self, raw, expected): + assert clean_title(raw) == expected + + def test_strips_stacked_suffixes(self): + assert clean_title("Ugnslax - se & gör | Köket.se") == "Ugnslax" + + +class TestSlugify: + def test_transliterates_swedish_characters(self): + assert slugify("Kycklinggryta med äpple och lök") == "kycklinggryta-med-apple-och-lok" + + def test_expands_ampersand(self): + assert slugify("Förrätter & tilltugg") == "forratter-och-tilltugg" + + +class TestFindEnglishLeftovers: + def test_flags_untranslated_units(self): + assert set(find_english_leftovers("2 cups flour, 1 tbsp butter")) == {"cups", "tbsp"} + + def test_clean_swedish_line_passes(self): + assert find_english_leftovers("2 dl vetemjöl, 1 msk smör") == [] + + def test_does_not_match_inside_swedish_words(self): + # "klyfta" contains no English token; substring matching would be wrong here. + assert find_english_leftovers("1 klyfta vitlök") == [] + + +class TestLooksMangled: + def test_flags_composed_fraction_from_parser(self): + assert looks_mangled("47⁄10 liter basmatiris") + + def test_flags_duplicated_unit(self): + assert looks_mangled("2 dl dl grädde") + + def test_clean_line_is_not_mangled(self): + assert not looks_mangled("4,7 dl basmatiris") + + +class TestExtractionFailure: + def test_detects_scraper_placeholder_in_ingredients(self): + recipe = {"recipeIngredient": [{"display": "Could not detect ingredients"}]} + assert has_extraction_failure(recipe) + + def test_clean_recipe_passes(self): + recipe = {"recipeIngredient": [{"display": "2 dl grädde"}]} + assert not has_extraction_failure(recipe) + + +class TestIngredientDisplayLines: + def test_prefers_display_then_note_then_food(self): + recipe = { + "recipeIngredient": [ + {"display": "2 dl grädde", "note": "ignored"}, + {"display": "", "note": "1 gul lök"}, + {"display": "", "note": "", "food": {"name": "salt"}}, + ] + } + assert ingredient_display_lines(recipe) == ["2 dl grädde", "1 gul lök", "salt"] diff --git a/tests/test_scaling.py b/tests/test_scaling.py new file mode 100644 index 0000000..aaf0875 --- /dev/null +++ b/tests/test_scaling.py @@ -0,0 +1,5 @@ +from mealie_mcp.server import scale_ingredients + + +def test_module_imports(): + assert callable(scale_ingredients) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..2b67c02 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,171 @@ +"""Server-layer behaviour, driven through a mocked HTTP transport.""" +from __future__ import annotations + +import httpx +import pytest + +from mealie_mcp import server + + +@pytest.fixture +def mock_mealie(monkeypatch): + """Install a fake Mealie and record every request the server makes.""" + def install(handler): + recorded: list[httpx.Request] = [] + + def wrapped(request: httpx.Request) -> httpx.Response: + recorded.append(request) + return handler(request) + + def fake_client() -> httpx.Client: + return httpx.Client( + base_url="https://mealie.test", + headers={"Authorization": "Bearer test", "User-Agent": server.UA}, + transport=httpx.MockTransport(wrapped), + ) + + monkeypatch.setattr(server, "_client", fake_client) + return recorded + + return install + + +class TestErrorMapping: + def test_401_is_reported_as_an_auth_problem(self, mock_mealie): + mock_mealie(lambda r: httpx.Response(401, json={"detail": "Could not validate credentials"})) + result = server.check_auth() + assert result["ok"] is False + assert result["reason"] == "auth" + assert "token" in result["detail"] + + def test_cloudflare_1010_is_not_mistaken_for_bad_credentials(self, mock_mealie): + # Verified failure: the external hostname returns 403 + "error code: 1010" + # for blocked client fingerprints. Calling that an auth error sends + # debugging down the wrong path. + mock_mealie(lambda r: httpx.Response(403, text="error code: 1010")) + result = server.check_auth() + assert result["reason"] == "waf" + assert "1010" in result["detail"] + + def test_healthy_instance_reports_ok(self, mock_mealie): + mock_mealie(lambda r: httpx.Response(200, json={"username": "fredrik"})) + result = server.check_auth() + assert result["ok"] is True + assert result["user"] == "fredrik" + + +class TestUserAgent: + def test_requests_never_use_the_default_user_agent(self, mock_mealie): + recorded = mock_mealie(lambda r: httpx.Response(200, json={"username": "fredrik"})) + server.check_auth() + agent = recorded[0].headers["user-agent"] + assert agent == server.UA + assert not agent.startswith(("python-httpx", "Python-urllib")) + + +class TestDuplicateDetection: + def test_import_stops_when_the_source_url_already_exists(self, mock_mealie): + existing = {"slug": "kycklinggryta", "name": "Kycklinggryta", "orgURL": "https://koket.se/a"} + mock_mealie(lambda r: httpx.Response(200, json={"items": [existing]})) + result = server.import_recipe_url("https://koket.se/a") + assert result["imported"] is False + assert result["reason"] == "duplicate" + + def test_trailing_slash_does_not_hide_a_duplicate(self, mock_mealie): + existing = {"slug": "x", "name": "X", "orgURL": "https://koket.se/a/"} + mock_mealie(lambda r: httpx.Response(200, json={"items": [existing]})) + assert server.find_by_source_url("https://koket.se/a") + + +class TestImportReport: + def test_report_flags_failed_extraction_and_junk_title(self): + recipe = { + "slug": "flaskfile", + "name": "Fläskfilé med svampsås - se & gör", + "recipeIngredient": [{"display": "Could not detect ingredients"}], + "recipeInstructions": [], + "image": "", + } + report = server._import_report(recipe) + assert report["extraction_failed"] is True + assert report["suggested_title"] == "Fläskfilé med svampsås" + assert report["has_image_field"] is False + + def test_clean_import_suggests_no_title_change(self): + recipe = {"slug": "lax", "name": "Lax i ugn", "recipeIngredient": [], "image": "img"} + assert server._import_report(recipe)["suggested_title"] is None + + +class TestParseIngredients: + def test_swedish_display_text_survives_the_parser(self, mock_mealie): + canonical = "4,7 dl basmatiris" + recipe = { + "slug": "ris", + "recipeIngredient": [{"display": canonical, "note": canonical}], + } + seen: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + import json as _json + + if request.url.path == "/api/parser/ingredients": + seen["sent"] = _json.loads(request.content)["ingredients"] + # The parser returns mangled display text; it must not be stored. + return httpx.Response(200, json=[{ + "ingredient": { + "quantity": 4.7, + "unit": {"id": "u", "name": "dl"}, + "food": {"id": "f", "name": "basmatiris"}, + "display": "47⁄10 liter basmatiris", + } + }]) + if request.method == "PATCH": + seen["patched"] = _json.loads(request.content)["recipeIngredient"] + return httpx.Response(200, json={}) + return httpx.Response(200, json=recipe) + + mock_mealie(handler) + server.parse_ingredients("ris") + + # Decimal comma is normalized for the parser only... + assert seen["sent"] == ["4.7 dl basmatiris"] + patched = seen["patched"][0] + # ...while the stored human-facing line stays the original Swedish text. + assert patched["display"] == canonical + assert patched["note"] == canonical + # Structured data from the parser is still applied. + assert patched["quantity"] == 4.7 + assert patched["food"]["name"] == "basmatiris" + + def test_failed_structured_patch_preserves_the_readable_import(self, mock_mealie): + recipe = {"slug": "ris", "recipeIngredient": [{"display": "2 dl grädde"}]} + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/api/parser/ingredients": + return httpx.Response(200, json=[{"ingredient": {"quantity": 2}}]) + if request.method == "PATCH": + # Verified: this PATCH can 500 with a ValueError on this instance. + return httpx.Response(500, json={"detail": "ValueError"}) + return httpx.Response(200, json=recipe) + + mock_mealie(handler) + result = server.parse_ingredients("ris") + assert result["parsed"] is False + assert result["canonical_lines"] == ["2 dl grädde"] + + +class TestCoverImage: + def test_local_upload_sends_the_required_extension_field(self, mock_mealie, tmp_path): + # Verified: without the multipart `extension` field the upload fails validation. + image = tmp_path / "cover.webp" + image.write_bytes(b"fake") + recorded = mock_mealie(lambda r: httpx.Response(200, json={"slug": "x", "image": "img"})) + server.set_cover_image("x", image_path=str(image)) + upload = next(r for r in recorded if r.method == "PUT") + body = upload.content.decode("utf-8", errors="replace") + assert 'name="extension"' in body + assert "webp" in body + + def test_requires_a_source(self): + with pytest.raises(ValueError): + server.set_cover_image("x") diff --git a/tests/test_server_contracts.py b/tests/test_server_contracts.py new file mode 100644 index 0000000..a9210f8 --- /dev/null +++ b/tests/test_server_contracts.py @@ -0,0 +1,75 @@ +"""Boundary contracts for the verified Mealie instance quirks.""" +from __future__ import annotations + +import httpx +import pytest + +from mealie_mcp import server + + +def response(status: int, text: str = "") -> httpx.Response: + return httpx.Response(status, text=text, request=httpx.Request("GET", "https://example.test")) + + +class TestAuthenticationFailures: + def test_401_is_an_auth_problem(self): + with pytest.raises(server.MealieAuthError): + server._raise_for_status(response(401)) + + def test_cloudflare_1010_is_not_reported_as_bad_token(self): + with pytest.raises(server.MealieBlockedError): + server._raise_for_status(response(403, "error code: 1010")) + + +class TestUrlImportResponse: + def test_slug_only_import_response_is_resolved_before_reporting(self, monkeypatch): + recipe = {"slug": "lax-med-citron", "name": "Lax med citron", "recipeIngredient": []} + calls: list[tuple[str, str]] = [] + + def request(method, path, **kwargs): + calls.append((method, path)) + assert kwargs["json"] == {"url": "https://example.test/lax"} + return "lax-med-citron" + + monkeypatch.setattr(server, "find_by_source_url", lambda _url: []) + monkeypatch.setattr(server, "_request", request) + monkeypatch.setattr(server, "get_recipe", lambda slug: recipe if slug == "lax-med-citron" else None) + + result = server.import_recipe_url("https://example.test/lax") + + assert calls == [("POST", "/api/recipes/create/url")] + assert result["imported"] is True + assert result["recipe"] == recipe + assert result["report"]["slug"] == "lax-med-citron" + + def test_duplicate_source_url_stops_before_creating(self, monkeypatch): + monkeypatch.setattr( + server, + "find_by_source_url", + lambda _url: [{"slug": "redan-finns", "name": "Redan finns"}], + ) + monkeypatch.setattr(server, "_request", lambda *_args, **_kwargs: pytest.fail("must not import")) + + result = server.import_recipe_url("https://example.test/recept") + + assert result == { + "imported": False, + "reason": "duplicate", + "existing": [{"slug": "redan-finns", "name": "Redan finns"}], + } + + +class TestPatchContract: + def test_patch_recipe_reads_back_the_final_object(self, monkeypatch): + seen = [] + updated = {"slug": "soppa", "name": "Soppa", "tags": [{"name": "📅 Vardag"}]} + + def request(method, path, **kwargs): + seen.append((method, path, kwargs)) + return None + + monkeypatch.setattr(server, "_request", request) + monkeypatch.setattr(server, "get_recipe", lambda slug: updated if slug == "soppa" else None) + + assert server.patch_recipe("soppa", {"tags": updated["tags"]}) == updated + assert seen == [("PATCH", "/api/recipes/soppa", {"json": {"tags": updated["tags"]}})] diff --git a/tests/test_verify.py b/tests/test_verify.py new file mode 100644 index 0000000..4e31650 --- /dev/null +++ b/tests/test_verify.py @@ -0,0 +1,139 @@ +"""The finished-import checklist must fail loudly on every verified failure mode.""" +from __future__ import annotations + +import pytest + +from mealie_mcp.verify import is_parsed, verify_recipe + + +def complete_recipe(**overrides): + """A recipe that satisfies every check, so each test can break exactly one thing.""" + recipe = { + "name": "Kycklinggryta med kokos", + "slug": "kycklinggryta-med-kokos", + "description": "Krämig gryta med kokosmjölk och röd curry.", + "recipeInstructions": [{"text": "Bryn kycklingen."}, {"text": "Häll i kokosmjölken."}], + "recipeIngredient": [ + { + "display": "400 g kycklinglårfilé", + "quantity": 400, + "unit": {"id": "u1", "name": "g"}, + "food": {"id": "f1", "name": "kycklinglårfilé"}, + }, + { + "display": "1 burk kokosmjölk", + "quantity": 1, + "unit": {"id": "u2", "name": "burk"}, + "food": {"id": "f2", "name": "kokosmjölk"}, + }, + ], + "image": "abc123", + "recipeCategory": [{"name": "Huvudrätter"}], + "tags": [{"name": "Källa: Köket"}], + "orgURL": "https://www.koket.se/kycklinggryta", + } + recipe.update(overrides) + return recipe + + +def status_of(report, check): + return next(c["status"] for c in report["checks"] if c["check"] == check) + + +class TestCompleteRecipe: + def test_fully_finished_import_passes(self): + report = verify_recipe(complete_recipe(), image_verified=True) + assert report["complete"] is True + assert report["failed"] == [] + + +class TestCoverImage: + def test_image_field_alone_is_only_a_warning_not_a_pass(self): + # Verified: a non-empty image field does not prove the UI shows a cover. + report = verify_recipe(complete_recipe(), image_verified=False) + assert status_of(report, "cover_image") == "warn" + + def test_missing_image_fails(self): + report = verify_recipe(complete_recipe(image=None)) + assert status_of(report, "cover_image") == "fail" + assert report["complete"] is False + + +class TestSwedishContent: + def test_english_ingredient_line_fails(self): + recipe = complete_recipe() + recipe["recipeIngredient"][0]["display"] = "2 cups flour" + report = verify_recipe(recipe, image_verified=True) + assert status_of(report, "swedish_ingredients") == "fail" + assert report["complete"] is False + + def test_english_instructions_fail(self): + recipe = complete_recipe(recipeInstructions=[{"text": "Add 2 tbsp of butter"}]) + report = verify_recipe(recipe, image_verified=True) + assert status_of(report, "swedish_prose") == "fail" + + def test_swedish_title_only_is_not_enough(self): + # Rule: translation is not done until the ingredient lines themselves are Swedish. + recipe = complete_recipe() + recipe["recipeIngredient"][1]["display"] = "1 clove garlic" + report = verify_recipe(recipe, image_verified=True) + assert report["complete"] is False + + +class TestParseState: + def test_unstructured_ingredients_leave_recipe_unparsed(self): + recipe = complete_recipe( + recipeIngredient=[{"display": "400 g kycklinglårfilé"}, {"display": "1 burk kokosmjölk"}] + ) + assert is_parsed(recipe) is False + report = verify_recipe(recipe, image_verified=True) + assert status_of(report, "ingredients_parsed") == "fail" + + def test_empty_recipe_is_not_parsed(self): + assert is_parsed({"recipeIngredient": []}) is False + + def test_structured_ingredients_are_parsed(self): + assert is_parsed(complete_recipe()) is True + + +class TestReadability: + def test_parser_mangled_display_fails_even_when_structured(self): + # Structure is worthless if the human-facing line is garbage. + recipe = complete_recipe() + recipe["recipeIngredient"][0]["display"] = "47⁄10 liter basmatiris" + report = verify_recipe(recipe, image_verified=True) + assert status_of(report, "ingredient_lines_readable") == "fail" + + +class TestExtractionPlaceholders: + def test_could_not_detect_placeholder_fails(self): + recipe = complete_recipe() + recipe["recipeIngredient"][0]["display"] = "Could not detect ingredients" + report = verify_recipe(recipe, image_verified=True) + assert status_of(report, "no_extraction_placeholders") == "fail" + + +class TestTaxonomy: + def test_untagged_recipe_fails_without_a_reason(self): + report = verify_recipe( + complete_recipe(recipeCategory=[], tags=[]), image_verified=True + ) + assert status_of(report, "taxonomy") == "fail" + + def test_deliberate_skip_passes(self): + report = verify_recipe( + complete_recipe(recipeCategory=[], tags=[]), + image_verified=True, + taxonomy_skipped_reason="Fredrik sorterar den manuellt", + ) + assert status_of(report, "taxonomy") == "pass" + assert report["complete"] is True + + +class TestAttribution: + def test_missing_source_warns_but_does_not_block(self): + report = verify_recipe( + complete_recipe(orgURL=None, extras={}), image_verified=True + ) + assert status_of(report, "attribution") == "warn" + assert report["complete"] is True