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:
2026-07-31 07:31:10 +02:00
committed by fredamn76
commit f2d233b430
17 changed files with 1643 additions and 0 deletions
+11
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
.venv/
venv/
.pytest_cache/
*.egg-info/
dist/
build/
.env
+112
View File
@@ -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`.
+1
View File
@@ -0,0 +1 @@
"""Mealie MCP server."""
+106
View File
@@ -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 (``4710 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
+44
View File
@@ -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 `4710 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
+31
View File
@@ -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.
+463
View File
@@ -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 ``4710 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()
+199
View File
@@ -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,
}
+22
View File
@@ -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"]
+99
View File
@@ -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("4710 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"]
+5
View File
@@ -0,0 +1,5 @@
from mealie_mcp.server import scale_ingredients
def test_module_imports():
assert callable(scale_ingredients)
+171
View File
@@ -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": "4710 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")
+75
View File
@@ -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"]}})]
+139
View File
@@ -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"] = "4710 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