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